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 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_rcstr::rcstr;
21use turbo_tasks::{NonLocalValue, ResolvedVc, Vc, debug::ValueDebugFormat, trace::TraceRawVcs};
22use turbo_tasks_fs::{File, FileContent, FileSystemPath};
23use turbopack_core::{
24    error::PrettyPrintError,
25    issue::{
26        Issue, IssueExt, IssueSeverity, IssueSource, IssueStage, OptionIssueSource,
27        OptionStyledString, StyledString,
28    },
29    source::Source,
30};
31
32use self::svg::calculate;
33
34/// Small placeholder version of the image.
35#[derive(PartialEq, Eq, Serialize, Deserialize, TraceRawVcs, ValueDebugFormat, NonLocalValue)]
36pub struct BlurPlaceholder {
37    pub data_url: String,
38    pub width: u32,
39    pub height: u32,
40}
41
42impl BlurPlaceholder {
43    pub fn fallback() -> Self {
44        BlurPlaceholder {
45            data_url: "data:image/gif;base64,R0lGODlhAQABAIAAAP///\
46                       wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
47                .to_string(),
48            width: 1,
49            height: 1,
50        }
51    }
52}
53
54/// Gathered meta information about an image.
55#[allow(clippy::manual_non_exhaustive)]
56#[serde_as]
57#[turbo_tasks::value]
58#[derive(Default)]
59#[non_exhaustive]
60pub struct ImageMetaData {
61    pub width: u32,
62    pub height: u32,
63    #[turbo_tasks(trace_ignore, debug_ignore)]
64    #[serde_as(as = "Option<DisplayFromStr>")]
65    pub mime_type: Option<Mime>,
66    pub blur_placeholder: Option<BlurPlaceholder>,
67}
68
69impl ImageMetaData {
70    pub fn fallback_value(mime_type: Option<Mime>) -> Self {
71        ImageMetaData {
72            width: 100,
73            height: 100,
74            mime_type,
75            blur_placeholder: Some(BlurPlaceholder::fallback()),
76        }
77    }
78}
79
80/// Options for generating a blur placeholder.
81#[turbo_tasks::value(shared)]
82pub struct BlurPlaceholderOptions {
83    pub quality: u8,
84    pub size: u32,
85}
86
87fn extension_to_image_format(extension: &str) -> Option<ImageFormat> {
88    Some(match extension {
89        "avif" => ImageFormat::Avif,
90        "jpg" | "jpeg" => ImageFormat::Jpeg,
91        "png" => ImageFormat::Png,
92        "gif" => ImageFormat::Gif,
93        "webp" => ImageFormat::WebP,
94        "tif" | "tiff" => ImageFormat::Tiff,
95        "tga" => ImageFormat::Tga,
96        "dds" => ImageFormat::Dds,
97        "bmp" => ImageFormat::Bmp,
98        "ico" => ImageFormat::Ico,
99        "hdr" => ImageFormat::Hdr,
100        "exr" => ImageFormat::OpenExr,
101        "pbm" | "pam" | "ppm" | "pgm" => ImageFormat::Pnm,
102        "ff" | "farbfeld" => ImageFormat::Farbfeld,
103        "qoi" => ImageFormat::Qoi,
104        _ => return None,
105    })
106}
107
108fn result_to_issue<T>(source: ResolvedVc<Box<dyn Source>>, result: Result<T>) -> Option<T> {
109    match result {
110        Ok(r) => Some(r),
111        Err(err) => {
112            ImageProcessingIssue {
113                message: StyledString::Text(format!("{}", PrettyPrintError(&err)).into())
114                    .resolved_cell(),
115                issue_severity: None,
116                title: None,
117                source: IssueSource::from_source_only(source),
118            }
119            .resolved_cell()
120            .emit();
121            None
122        }
123    }
124}
125
126fn load_image(
127    path: ResolvedVc<Box<dyn Source>>,
128    bytes: &[u8],
129    extension: &str,
130) -> Option<(ImageBuffer, Option<ImageFormat>)> {
131    result_to_issue(path, load_image_internal(path, bytes, extension))
132}
133
134/// Type of raw image buffer read by reader from `load_image`.
135/// If the image could not be decoded, the raw bytes are returned.
136enum ImageBuffer {
137    Raw(Vec<u8>),
138    Decoded(image::DynamicImage),
139}
140
141fn load_image_internal(
142    image: ResolvedVc<Box<dyn Source>>,
143    bytes: &[u8],
144    extension: &str,
145) -> Result<(ImageBuffer, Option<ImageFormat>)> {
146    let reader = image::io::Reader::new(Cursor::new(&bytes));
147    let mut reader = reader
148        .with_guessed_format()
149        .context("unable to determine image format from file content")?;
150    let mut format = reader.format();
151    if format.is_none()
152        && let Some(new_format) = extension_to_image_format(extension)
153    {
154        format = Some(new_format);
155        reader.set_format(new_format);
156    }
157
158    // [NOTE]
159    // Workaround for missing codec supports in Turbopack,
160    // Instead of erroring out the whole build, emitting raw image bytes as-is
161    // (Not applying resize, not applying optimization or anything else)
162    // and expect a browser decodes it.
163    // This is a stop gap until we have proper encoding/decoding in majority of the
164    // platforms
165
166    #[cfg(not(feature = "avif"))]
167    if matches!(format, Some(ImageFormat::Avif)) {
168        ImageProcessingIssue {
169            source: IssueSource::from_source_only(image),
170            message: StyledString::Text(rcstr!(
171                "This version of Turbopack does not support AVIF images, will emit without \
172                 optimization or encoding"
173            ))
174            .resolved_cell(),
175            title: Some(StyledString::Text(rcstr!("AVIF image not supported")).resolved_cell()),
176            issue_severity: Some(IssueSeverity::Warning),
177        }
178        .resolved_cell()
179        .emit();
180        return Ok((ImageBuffer::Raw(bytes.to_vec()), format));
181    }
182
183    #[cfg(not(feature = "webp"))]
184    if matches!(format, Some(ImageFormat::WebP)) {
185        ImageProcessingIssue {
186            source: IssueSource::from_source_only(image),
187            message: StyledString::Text(rcstr!(
188                "This version of Turbopack does not support WEBP images, will emit without \
189                 optimization or encoding"
190            ))
191            .resolved_cell(),
192            title: Some(StyledString::Text(rcstr!("WEBP image not supported")).resolved_cell()),
193            issue_severity: Some(IssueSeverity::Warning),
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<Box<dyn Source>>,
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                source: IssueSource::from_source_only(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            // mime does not support typed IMAGE_X_ICO yet
260            (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/// Analyze an image and return meta information about it.
340/// Optionally computes a blur placeholder.
341#[turbo_tasks::function]
342pub async fn get_meta_data(
343    image: ResolvedVc<Box<dyn Source>>,
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 = image.ident().path().await?;
352    let extension = path.extension();
353
354    if extension == "svg" {
355        let content = result_to_issue(
356            image,
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            image,
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_buffer, format)) = load_image(image, &bytes, extension) else {
378        return Ok(ImageMetaData::fallback_value(None).cell());
379    };
380
381    match image_buffer {
382        ImageBuffer::Raw(..) => Ok(ImageMetaData::fallback_value(None).cell()),
383        ImageBuffer::Decoded(image_data) => {
384            let (width, height) = image_data.dimensions();
385            let blur_placeholder = if let Some(blur_placeholder) = blur_placeholder {
386                if matches!(
387                    format,
388                    // list should match next/client/image.tsx
389                    Some(ImageFormat::Png)
390                        | Some(ImageFormat::Jpeg)
391                        | Some(ImageFormat::WebP)
392                        | Some(ImageFormat::Avif)
393                ) {
394                    compute_blur_data(
395                        image,
396                        image_data,
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    source: ResolvedVc<Box<dyn Source>>,
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 = source.ident().path().await?;
435    let extension = path.extension();
436
437    let Some((image, format)) = load_image(source, &bytes, extension) 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            // Falls back to image/jpeg if the format is unknown, thouogh it is not
465            // technically correct
466            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    message: ResolvedVc<StyledString>,
490    title: Option<ResolvedVc<StyledString>>,
491    issue_severity: Option<IssueSeverity>,
492    source: IssueSource,
493}
494
495#[turbo_tasks::value_impl]
496impl Issue for ImageProcessingIssue {
497    fn severity(&self) -> IssueSeverity {
498        self.issue_severity.unwrap_or(IssueSeverity::Error)
499    }
500
501    #[turbo_tasks::function]
502    fn file_path(&self) -> Vc<FileSystemPath> {
503        self.source.file_path()
504    }
505
506    #[turbo_tasks::function]
507    fn stage(&self) -> Vc<IssueStage> {
508        IssueStage::Transform.cell()
509    }
510
511    #[turbo_tasks::function]
512    fn title(&self) -> Vc<StyledString> {
513        *self
514            .title
515            .unwrap_or(StyledString::Text(rcstr!("Processing image failed")).resolved_cell())
516    }
517
518    #[turbo_tasks::function]
519    fn description(&self) -> Vc<OptionStyledString> {
520        Vc::cell(Some(self.message))
521    }
522
523    #[turbo_tasks::function]
524    fn source(&self) -> Vc<OptionIssueSource> {
525        Vc::cell(Some(self.source))
526    }
527}