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