1pub mod svg;
2
3use std::{io::Cursor, str::FromStr};
4
5use anyhow::{Context, Result, bail};
6use base64::{display::Base64Display, engine::general_purpose::STANDARD};
7use image::{
8 DynamicImage, GenericImageView, ImageEncoder, ImageFormat,
9 codecs::{
10 bmp::BmpEncoder,
11 ico::IcoEncoder,
12 jpeg::JpegEncoder,
13 png::{CompressionType, PngEncoder},
14 },
15 imageops::FilterType,
16};
17use mime::Mime;
18use serde::{Deserialize, Serialize};
19use serde_with::{DisplayFromStr, serde_as};
20use turbo_tasks::{NonLocalValue, ResolvedVc, Vc, debug::ValueDebugFormat, trace::TraceRawVcs};
21use turbo_tasks_fs::{File, FileContent, FileSystemPath};
22use turbopack_core::{
23 error::PrettyPrintError,
24 ident::AssetIdent,
25 issue::{Issue, IssueExt, IssueSeverity, IssueStage, OptionStyledString, StyledString},
26};
27
28use self::svg::calculate;
29
30#[derive(PartialEq, Eq, Serialize, Deserialize, TraceRawVcs, ValueDebugFormat, NonLocalValue)]
32pub struct BlurPlaceholder {
33 pub data_url: String,
34 pub width: u32,
35 pub height: u32,
36}
37
38impl BlurPlaceholder {
39 pub fn fallback() -> Self {
40 BlurPlaceholder {
41 data_url: "data:image/gif;base64,R0lGODlhAQABAIAAAP///\
42 wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
43 .to_string(),
44 width: 1,
45 height: 1,
46 }
47 }
48}
49
50#[allow(clippy::manual_non_exhaustive)]
52#[serde_as]
53#[turbo_tasks::value]
54#[derive(Default)]
55#[non_exhaustive]
56pub struct ImageMetaData {
57 pub width: u32,
58 pub height: u32,
59 #[turbo_tasks(trace_ignore, debug_ignore)]
60 #[serde_as(as = "Option<DisplayFromStr>")]
61 pub mime_type: Option<Mime>,
62 pub blur_placeholder: Option<BlurPlaceholder>,
63}
64
65impl ImageMetaData {
66 pub fn fallback_value(mime_type: Option<Mime>) -> Self {
67 ImageMetaData {
68 width: 100,
69 height: 100,
70 mime_type,
71 blur_placeholder: Some(BlurPlaceholder::fallback()),
72 }
73 }
74}
75
76#[turbo_tasks::value(shared)]
78pub struct BlurPlaceholderOptions {
79 pub quality: u8,
80 pub size: u32,
81}
82
83fn extension_to_image_format(extension: &str) -> Option<ImageFormat> {
84 Some(match extension {
85 "avif" => ImageFormat::Avif,
86 "jpg" | "jpeg" => ImageFormat::Jpeg,
87 "png" => ImageFormat::Png,
88 "gif" => ImageFormat::Gif,
89 "webp" => ImageFormat::WebP,
90 "tif" | "tiff" => ImageFormat::Tiff,
91 "tga" => ImageFormat::Tga,
92 "dds" => ImageFormat::Dds,
93 "bmp" => ImageFormat::Bmp,
94 "ico" => ImageFormat::Ico,
95 "hdr" => ImageFormat::Hdr,
96 "exr" => ImageFormat::OpenExr,
97 "pbm" | "pam" | "ppm" | "pgm" => ImageFormat::Pnm,
98 "ff" | "farbfeld" => ImageFormat::Farbfeld,
99 "qoi" => ImageFormat::Qoi,
100 _ => return None,
101 })
102}
103
104fn result_to_issue<T>(path: ResolvedVc<FileSystemPath>, result: Result<T>) -> Option<T> {
105 match result {
106 Ok(r) => Some(r),
107 Err(err) => {
108 ImageProcessingIssue {
109 path,
110 message: StyledString::Text(format!("{}", PrettyPrintError(&err)).into())
111 .resolved_cell(),
112 issue_severity: None,
113 title: None,
114 }
115 .resolved_cell()
116 .emit();
117 None
118 }
119 }
120}
121
122fn load_image(
123 path: ResolvedVc<FileSystemPath>,
124 bytes: &[u8],
125 extension: Option<&str>,
126) -> Option<(ImageBuffer, Option<ImageFormat>)> {
127 result_to_issue(path, load_image_internal(path, bytes, extension))
128}
129
130enum ImageBuffer {
133 Raw(Vec<u8>),
134 Decoded(image::DynamicImage),
135}
136
137fn load_image_internal(
138 path: ResolvedVc<FileSystemPath>,
139 bytes: &[u8],
140 extension: Option<&str>,
141) -> Result<(ImageBuffer, Option<ImageFormat>)> {
142 let reader = image::io::Reader::new(Cursor::new(&bytes));
143 let mut reader = reader
144 .with_guessed_format()
145 .context("unable to determine image format from file content")?;
146 let mut format = reader.format();
147 if format.is_none() {
148 if let Some(extension) = extension {
149 if let Some(new_format) = extension_to_image_format(extension) {
150 format = Some(new_format);
151 reader.set_format(new_format);
152 }
153 }
154 }
155
156 #[cfg(not(feature = "avif"))]
165 if matches!(format, Some(ImageFormat::Avif)) {
166 ImageProcessingIssue {
167 path,
168 message: StyledString::Text(
169 "This version of Turbopack does not support AVIF images, will emit without \
170 optimization or encoding"
171 .into(),
172 )
173 .resolved_cell(),
174 title: Some(StyledString::Text("AVIF image not supported".into()).resolved_cell()),
175 issue_severity: Some(IssueSeverity::Warning.resolved_cell()),
176 }
177 .resolved_cell()
178 .emit();
179 return Ok((ImageBuffer::Raw(bytes.to_vec()), format));
180 }
181
182 #[cfg(not(feature = "webp"))]
183 if matches!(format, Some(ImageFormat::WebP)) {
184 ImageProcessingIssue {
185 path,
186 message: StyledString::Text(
187 "This version of Turbopack does not support WEBP images, will emit without \
188 optimization or encoding"
189 .into(),
190 )
191 .resolved_cell(),
192 title: Some(StyledString::Text("WEBP image not supported".into()).resolved_cell()),
193 issue_severity: Some(IssueSeverity::Warning.resolved_cell()),
194 }
195 .resolved_cell()
196 .emit();
197 return Ok((ImageBuffer::Raw(bytes.to_vec()), format));
198 }
199
200 let image = reader.decode().context("unable to decode image data")?;
201 Ok((ImageBuffer::Decoded(image), format))
202}
203
204fn compute_blur_data(
205 path: ResolvedVc<FileSystemPath>,
206 image: image::DynamicImage,
207 format: ImageFormat,
208 options: &BlurPlaceholderOptions,
209) -> Option<BlurPlaceholder> {
210 match compute_blur_data_internal(image, format, options)
211 .context("unable to compute blur placeholder")
212 {
213 Ok(r) => Some(r),
214 Err(err) => {
215 ImageProcessingIssue {
216 path,
217 message: StyledString::Text(format!("{}", PrettyPrintError(&err)).into())
218 .resolved_cell(),
219 issue_severity: None,
220 title: None,
221 }
222 .resolved_cell()
223 .emit();
224 Some(BlurPlaceholder::fallback())
225 }
226 }
227}
228
229fn encode_image(image: DynamicImage, format: ImageFormat, quality: u8) -> Result<(Vec<u8>, Mime)> {
230 let mut buf = Vec::new();
231 let (width, height) = image.dimensions();
232
233 Ok(match format {
234 ImageFormat::Png => {
235 PngEncoder::new_with_quality(
236 &mut buf,
237 CompressionType::Best,
238 image::codecs::png::FilterType::NoFilter,
239 )
240 .write_image(image.as_bytes(), width, height, image.color().into())?;
241 (buf, mime::IMAGE_PNG)
242 }
243 ImageFormat::Jpeg => {
244 JpegEncoder::new_with_quality(&mut buf, quality).write_image(
245 image.as_bytes(),
246 width,
247 height,
248 image.color().into(),
249 )?;
250 (buf, mime::IMAGE_JPEG)
251 }
252 ImageFormat::Ico => {
253 IcoEncoder::new(&mut buf).write_image(
254 image.as_bytes(),
255 width,
256 height,
257 image.color().into(),
258 )?;
259 (buf, Mime::from_str("image/x-icon")?)
261 }
262 ImageFormat::Bmp => {
263 BmpEncoder::new(&mut buf).write_image(
264 image.as_bytes(),
265 width,
266 height,
267 image.color().into(),
268 )?;
269 (buf, mime::IMAGE_BMP)
270 }
271 #[cfg(feature = "webp")]
272 ImageFormat::WebP => {
273 use image::codecs::webp::WebPEncoder;
274 let encoder = WebPEncoder::new_lossless(&mut buf);
275 encoder.encode(image.as_bytes(), width, height, image.color().into())?;
276
277 (buf, Mime::from_str("image/webp")?)
278 }
279 #[cfg(feature = "avif")]
280 ImageFormat::Avif => {
281 use image::codecs::avif::AvifEncoder;
282 AvifEncoder::new_with_speed_quality(&mut buf, 6, quality).write_image(
283 image.as_bytes(),
284 width,
285 height,
286 image.color().into(),
287 )?;
288 (buf, Mime::from_str("image/avif")?)
289 }
290 _ => bail!(
291 "Encoding for image format {:?} has not been compiled into the current build",
292 format
293 ),
294 })
295}
296
297fn compute_blur_data_internal(
298 image: image::DynamicImage,
299 format: ImageFormat,
300 options: &BlurPlaceholderOptions,
301) -> Result<BlurPlaceholder> {
302 let small_image = image.resize(options.size, options.size, FilterType::Triangle);
303 let width = small_image.width();
304 let height = small_image.height();
305 let (data, mime) = encode_image(small_image, format, options.quality)?;
306 let data_url = format!(
307 "data:{mime};base64,{}",
308 Base64Display::new(&data, &STANDARD)
309 );
310
311 Ok(BlurPlaceholder {
312 data_url,
313 width,
314 height,
315 })
316}
317
318fn image_format_to_mime_type(format: ImageFormat) -> Result<Option<Mime>> {
319 Ok(match format {
320 ImageFormat::Png => Some(mime::IMAGE_PNG),
321 ImageFormat::Jpeg => Some(mime::IMAGE_JPEG),
322 ImageFormat::WebP => Some(Mime::from_str("image/webp")?),
323 ImageFormat::Avif => Some(Mime::from_str("image/avif")?),
324 ImageFormat::Bmp => Some(mime::IMAGE_BMP),
325 ImageFormat::Dds => Some(Mime::from_str("image/vnd-ms.dds")?),
326 ImageFormat::Farbfeld => Some(mime::APPLICATION_OCTET_STREAM),
327 ImageFormat::Gif => Some(mime::IMAGE_GIF),
328 ImageFormat::Hdr => Some(Mime::from_str("image/vnd.radiance")?),
329 ImageFormat::Ico => Some(Mime::from_str("image/x-icon")?),
330 ImageFormat::OpenExr => Some(Mime::from_str("image/x-exr")?),
331 ImageFormat::Pnm => Some(Mime::from_str("image/x-portable-anymap")?),
332 ImageFormat::Qoi => Some(mime::APPLICATION_OCTET_STREAM),
333 ImageFormat::Tga => Some(Mime::from_str("image/x-tga")?),
334 ImageFormat::Tiff => Some(Mime::from_str("image/tiff")?),
335 _ => None,
336 })
337}
338
339#[turbo_tasks::function]
342pub async fn get_meta_data(
343 ident: Vc<AssetIdent>,
344 content: Vc<FileContent>,
345 blur_placeholder: Option<Vc<BlurPlaceholderOptions>>,
346) -> Result<Vc<ImageMetaData>> {
347 let FileContent::Content(content) = &*content.await? else {
348 bail!("Input image not found");
349 };
350 let bytes = content.content().to_bytes()?;
351 let path_resolved = ident.path().to_resolved().await?;
352 let path = path_resolved.await?;
353 let extension = path.extension_ref();
354 if extension == Some("svg") {
355 let content = result_to_issue(
356 path_resolved,
357 std::str::from_utf8(&bytes).context("Input image is not valid utf-8"),
358 );
359 let Some(content) = content else {
360 return Ok(ImageMetaData::fallback_value(Some(mime::IMAGE_SVG)).cell());
361 };
362 let info = result_to_issue(
363 path_resolved,
364 calculate(content).context("Failed to parse svg source code for image dimensions"),
365 );
366 let Some((width, height)) = info else {
367 return Ok(ImageMetaData::fallback_value(Some(mime::IMAGE_SVG)).cell());
368 };
369 return Ok(ImageMetaData {
370 width,
371 height,
372 mime_type: Some(mime::IMAGE_SVG),
373 blur_placeholder: None,
374 }
375 .cell());
376 }
377 let Some((image, format)) = load_image(path_resolved, &bytes, extension) else {
378 return Ok(ImageMetaData::fallback_value(None).cell());
379 };
380
381 match image {
382 ImageBuffer::Raw(..) => Ok(ImageMetaData::fallback_value(None).cell()),
383 ImageBuffer::Decoded(image) => {
384 let (width, height) = image.dimensions();
385 let blur_placeholder = if let Some(blur_placeholder) = blur_placeholder {
386 if matches!(
387 format,
388 Some(ImageFormat::Png)
390 | Some(ImageFormat::Jpeg)
391 | Some(ImageFormat::WebP)
392 | Some(ImageFormat::Avif)
393 ) {
394 compute_blur_data(
395 path_resolved,
396 image,
397 format.unwrap(),
398 &*blur_placeholder.await?,
399 )
400 } else {
401 None
402 }
403 } else {
404 None
405 };
406
407 Ok(ImageMetaData {
408 width,
409 height,
410 mime_type: if let Some(format) = format {
411 image_format_to_mime_type(format)?
412 } else {
413 None
414 },
415 blur_placeholder,
416 }
417 .cell())
418 }
419 }
420}
421
422#[turbo_tasks::function]
423pub async fn optimize(
424 ident: Vc<AssetIdent>,
425 content: Vc<FileContent>,
426 max_width: u32,
427 max_height: u32,
428 quality: u8,
429) -> Result<Vc<FileContent>> {
430 let FileContent::Content(content) = &*content.await? else {
431 return Ok(FileContent::NotFound.cell());
432 };
433 let bytes = content.content().to_bytes()?;
434 let path = ident.path().to_resolved().await?;
435
436 let Some((image, format)) = load_image(path, &bytes, ident.path().await?.extension_ref())
437 else {
438 return Ok(FileContent::NotFound.cell());
439 };
440 match image {
441 ImageBuffer::Raw(buffer) => {
442 #[cfg(not(feature = "avif"))]
443 if matches!(format, Some(ImageFormat::Avif)) {
444 return Ok(FileContent::Content(
445 File::from(buffer).with_content_type(Mime::from_str("image/avif")?),
446 )
447 .cell());
448 }
449
450 #[cfg(not(feature = "webp"))]
451 if matches!(format, Some(ImageFormat::WebP)) {
452 return Ok(FileContent::Content(
453 File::from(buffer).with_content_type(Mime::from_str("image/webp")?),
454 )
455 .cell());
456 }
457
458 let mime_type = if let Some(format) = format {
459 image_format_to_mime_type(format)?
460 } else {
461 None
462 };
463
464 Ok(FileContent::Content(
467 File::from(buffer).with_content_type(mime_type.unwrap_or(mime::IMAGE_JPEG)),
468 )
469 .cell())
470 }
471 ImageBuffer::Decoded(image) => {
472 let (width, height) = image.dimensions();
473 let image = if width > max_width || height > max_height {
474 image.resize(max_width, max_height, FilterType::Lanczos3)
475 } else {
476 image
477 };
478
479 let format = format.unwrap_or(ImageFormat::Jpeg);
480 let (data, mime_type) = encode_image(image, format, quality)?;
481
482 Ok(FileContent::Content(File::from(data).with_content_type(mime_type)).cell())
483 }
484 }
485}
486
487#[turbo_tasks::value]
488struct ImageProcessingIssue {
489 path: ResolvedVc<FileSystemPath>,
490 message: ResolvedVc<StyledString>,
491 title: Option<ResolvedVc<StyledString>>,
492 issue_severity: Option<ResolvedVc<IssueSeverity>>,
493}
494
495#[turbo_tasks::value_impl]
496impl Issue for ImageProcessingIssue {
497 #[turbo_tasks::function]
498 fn severity(&self) -> Vc<IssueSeverity> {
499 self.issue_severity
500 .map(|s| *s)
501 .unwrap_or(IssueSeverity::Error.into())
502 }
503
504 #[turbo_tasks::function]
505 fn file_path(&self) -> Vc<FileSystemPath> {
506 *self.path
507 }
508
509 #[turbo_tasks::function]
510 fn stage(&self) -> Vc<IssueStage> {
511 IssueStage::Transform.cell()
512 }
513
514 #[turbo_tasks::function]
515 fn title(&self) -> Vc<StyledString> {
516 *self
517 .title
518 .unwrap_or(StyledString::Text("Processing image failed".into()).resolved_cell())
519 }
520
521 #[turbo_tasks::function]
522 fn description(&self) -> Vc<OptionStyledString> {
523 Vc::cell(Some(self.message))
524 }
525}