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