turbopack_image/process/
mod.rs

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