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