Skip to main content

turbopack_image/process/
mod.rs

1pub mod svg;
2
3use std::{io::Cursor, str::FromStr};
4
5use anyhow::{Context, Result, bail};
6use async_trait::async_trait;
7use base64::{display::Base64Display, engine::general_purpose::STANDARD};
8use bincode::{Decode, Encode};
9use image::{
10    DynamicImage, GenericImageView, ImageEncoder, ImageFormat,
11    codecs::{
12        bmp::BmpEncoder,
13        ico::IcoEncoder,
14        jpeg::JpegEncoder,
15        png::{CompressionType, PngEncoder},
16    },
17    imageops::FilterType,
18};
19use mime::Mime;
20use turbo_rcstr::rcstr;
21use turbo_tasks::{
22    NonLocalValue, PrettyPrintError, ResolvedVc, Vc, debug::ValueDebugFormat, trace::TraceRawVcs,
23};
24use turbo_tasks_fs::{File, FileContent, FileSystemPath};
25use turbopack_core::{
26    issue::{Issue, IssueExt, IssueSeverity, IssueSource, IssueStage, StyledString},
27    source::Source,
28};
29
30use self::svg::calculate;
31
32/// Small placeholder version of the image.
33#[derive(PartialEq, Eq, TraceRawVcs, ValueDebugFormat, NonLocalValue, Encode, Decode)]
34pub struct BlurPlaceholder {
35    pub data_url: String,
36    pub width: u32,
37    pub height: u32,
38}
39
40impl BlurPlaceholder {
41    pub fn fallback() -> Self {
42        BlurPlaceholder {
43            data_url: "data:image/gif;base64,R0lGODlhAQABAIAAAP///\
44                       wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
45                .to_string(),
46            width: 1,
47            height: 1,
48        }
49    }
50}
51
52/// Gathered meta information about an image.
53#[allow(clippy::manual_non_exhaustive)]
54#[turbo_tasks::value]
55#[derive(Default)]
56#[non_exhaustive]
57pub struct ImageMetaData {
58    pub width: u32,
59    pub height: u32,
60    #[turbo_tasks(trace_ignore, debug_ignore)]
61    #[bincode(with = "turbo_bincode::mime_option")]
62    pub mime_type: Option<Mime>,
63    pub blur_placeholder: Option<BlurPlaceholder>,
64}
65
66impl ImageMetaData {
67    pub fn fallback_value(mime_type: Option<Mime>) -> Self {
68        ImageMetaData {
69            width: 100,
70            height: 100,
71            mime_type,
72            blur_placeholder: Some(BlurPlaceholder::fallback()),
73        }
74    }
75}
76
77/// Options for generating a blur placeholder.
78#[turbo_tasks::value(shared)]
79pub struct BlurPlaceholderOptions {
80    pub quality: u8,
81    pub size: u32,
82}
83
84fn extension_to_image_format(extension: &str) -> Option<ImageFormat> {
85    Some(match extension {
86        "avif" => ImageFormat::Avif,
87        "jpg" | "jpeg" => ImageFormat::Jpeg,
88        "png" => ImageFormat::Png,
89        "gif" => ImageFormat::Gif,
90        "webp" => ImageFormat::WebP,
91        "tif" | "tiff" => ImageFormat::Tiff,
92        "tga" => ImageFormat::Tga,
93        "dds" => ImageFormat::Dds,
94        "bmp" => ImageFormat::Bmp,
95        "ico" => ImageFormat::Ico,
96        "hdr" => ImageFormat::Hdr,
97        "exr" => ImageFormat::OpenExr,
98        "pbm" | "pam" | "ppm" | "pgm" => ImageFormat::Pnm,
99        "ff" | "farbfeld" => ImageFormat::Farbfeld,
100        "qoi" => ImageFormat::Qoi,
101        _ => return None,
102    })
103}
104
105fn result_to_issue<T>(source: ResolvedVc<Box<dyn Source>>, result: Result<T>) -> Option<T> {
106    match result {
107        Ok(r) => Some(r),
108        Err(err) => {
109            ImageProcessingIssue {
110                message: StyledString::Text(format!("{}", PrettyPrintError(&err)).into())
111                    .resolved_cell(),
112                issue_severity: None,
113                title: None,
114                source: IssueSource::from_source_only(source),
115            }
116            .resolved_cell()
117            .emit();
118            None
119        }
120    }
121}
122
123fn load_image(
124    path: ResolvedVc<Box<dyn Source>>,
125    bytes: &[u8],
126    extension: Option<&str>,
127) -> Option<(ImageBuffer, Option<ImageFormat>)> {
128    result_to_issue(path, load_image_internal(path, bytes, extension))
129}
130
131/// Type of raw image buffer read by reader from `load_image`.
132/// If the image could not be decoded, the raw bytes are returned.
133enum ImageBuffer {
134    Raw(Vec<u8>),
135    Decoded(image::DynamicImage),
136}
137
138fn load_image_internal(
139    image: ResolvedVc<Box<dyn Source>>,
140    bytes: &[u8],
141    extension: Option<&str>,
142) -> Result<(ImageBuffer, Option<ImageFormat>)> {
143    let reader = image::ImageReader::new(Cursor::new(&bytes));
144    let mut reader = reader
145        .with_guessed_format()
146        .context("unable to determine image format from file content")?;
147    let mut format = reader.format();
148    if format.is_none()
149        && let Some(ext) = extension
150        && let Some(new_format) = extension_to_image_format(ext)
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 == Some("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#[async_trait]
494#[turbo_tasks::value_impl]
495impl Issue for ImageProcessingIssue {
496    fn severity(&self) -> IssueSeverity {
497        self.issue_severity.unwrap_or(IssueSeverity::Error)
498    }
499
500    async fn file_path(&self) -> anyhow::Result<FileSystemPath> {
501        self.source.file_path().owned().await
502    }
503
504    fn stage(&self) -> IssueStage {
505        IssueStage::Transform
506    }
507
508    async fn title(&self) -> anyhow::Result<StyledString> {
509        Ok(match self.title {
510            Some(t) => (*t.await?).clone(),
511            None => StyledString::Text(rcstr!("Processing image failed")),
512        })
513    }
514
515    async fn description(&self) -> anyhow::Result<Option<StyledString>> {
516        Ok(Some((*self.message.await?).clone()))
517    }
518
519    fn source(&self) -> Option<IssueSource> {
520        Some(self.source)
521    }
522}