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#[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#[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#[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
131enum 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 #[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 (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#[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 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 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}