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::{NonLocalValue, ResolvedVc, Vc, debug::ValueDebugFormat, trace::TraceRawVcs};
21use turbo_tasks_fs::{File, FileContent, FileSystemPath};
22use turbopack_core::{
23 error::PrettyPrintError,
24 issue::{
25 Issue, IssueExt, IssueSeverity, IssueSource, IssueStage, OptionIssueSource,
26 OptionStyledString, StyledString,
27 },
28 source::Source,
29};
30
31use self::svg::calculate;
32
33#[derive(PartialEq, Eq, TraceRawVcs, ValueDebugFormat, NonLocalValue, Encode, Decode)]
35pub struct BlurPlaceholder {
36 pub data_url: String,
37 pub width: u32,
38 pub height: u32,
39}
40
41impl BlurPlaceholder {
42 pub fn fallback() -> Self {
43 BlurPlaceholder {
44 data_url: "data:image/gif;base64,R0lGODlhAQABAIAAAP///\
45 wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
46 .to_string(),
47 width: 1,
48 height: 1,
49 }
50 }
51}
52
53#[allow(clippy::manual_non_exhaustive)]
55#[turbo_tasks::value]
56#[derive(Default)]
57#[non_exhaustive]
58pub struct ImageMetaData {
59 pub width: u32,
60 pub height: u32,
61 #[turbo_tasks(trace_ignore, debug_ignore)]
62 #[bincode(with = "turbo_bincode::mime_option")]
63 pub mime_type: Option<Mime>,
64 pub blur_placeholder: Option<BlurPlaceholder>,
65}
66
67impl ImageMetaData {
68 pub fn fallback_value(mime_type: Option<Mime>) -> Self {
69 ImageMetaData {
70 width: 100,
71 height: 100,
72 mime_type,
73 blur_placeholder: Some(BlurPlaceholder::fallback()),
74 }
75 }
76}
77
78#[turbo_tasks::value(shared)]
80pub struct BlurPlaceholderOptions {
81 pub quality: u8,
82 pub size: u32,
83}
84
85fn extension_to_image_format(extension: &str) -> Option<ImageFormat> {
86 Some(match extension {
87 "avif" => ImageFormat::Avif,
88 "jpg" | "jpeg" => ImageFormat::Jpeg,
89 "png" => ImageFormat::Png,
90 "gif" => ImageFormat::Gif,
91 "webp" => ImageFormat::WebP,
92 "tif" | "tiff" => ImageFormat::Tiff,
93 "tga" => ImageFormat::Tga,
94 "dds" => ImageFormat::Dds,
95 "bmp" => ImageFormat::Bmp,
96 "ico" => ImageFormat::Ico,
97 "hdr" => ImageFormat::Hdr,
98 "exr" => ImageFormat::OpenExr,
99 "pbm" | "pam" | "ppm" | "pgm" => ImageFormat::Pnm,
100 "ff" | "farbfeld" => ImageFormat::Farbfeld,
101 "qoi" => ImageFormat::Qoi,
102 _ => return None,
103 })
104}
105
106fn result_to_issue<T>(source: ResolvedVc<Box<dyn Source>>, result: Result<T>) -> Option<T> {
107 match result {
108 Ok(r) => Some(r),
109 Err(err) => {
110 ImageProcessingIssue {
111 message: StyledString::Text(format!("{}", PrettyPrintError(&err)).into())
112 .resolved_cell(),
113 issue_severity: None,
114 title: None,
115 source: IssueSource::from_source_only(source),
116 }
117 .resolved_cell()
118 .emit();
119 None
120 }
121 }
122}
123
124fn load_image(
125 path: ResolvedVc<Box<dyn Source>>,
126 bytes: &[u8],
127 extension: &str,
128) -> Option<(ImageBuffer, Option<ImageFormat>)> {
129 result_to_issue(path, load_image_internal(path, bytes, extension))
130}
131
132enum ImageBuffer {
135 Raw(Vec<u8>),
136 Decoded(image::DynamicImage),
137}
138
139fn load_image_internal(
140 image: ResolvedVc<Box<dyn Source>>,
141 bytes: &[u8],
142 extension: &str,
143) -> Result<(ImageBuffer, Option<ImageFormat>)> {
144 let reader = image::ImageReader::new(Cursor::new(&bytes));
145 let mut reader = reader
146 .with_guessed_format()
147 .context("unable to determine image format from file content")?;
148 let mut format = reader.format();
149 if format.is_none()
150 && let Some(new_format) = extension_to_image_format(extension)
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 == "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#[turbo_tasks::value_impl]
494impl Issue for ImageProcessingIssue {
495 fn severity(&self) -> IssueSeverity {
496 self.issue_severity.unwrap_or(IssueSeverity::Error)
497 }
498
499 #[turbo_tasks::function]
500 fn file_path(&self) -> Vc<FileSystemPath> {
501 self.source.file_path()
502 }
503
504 #[turbo_tasks::function]
505 fn stage(&self) -> Vc<IssueStage> {
506 IssueStage::Transform.cell()
507 }
508
509 #[turbo_tasks::function]
510 fn title(&self) -> Vc<StyledString> {
511 *self
512 .title
513 .unwrap_or(StyledString::Text(rcstr!("Processing image failed")).resolved_cell())
514 }
515
516 #[turbo_tasks::function]
517 fn description(&self) -> Vc<OptionStyledString> {
518 Vc::cell(Some(self.message))
519 }
520
521 #[turbo_tasks::function]
522 fn source(&self) -> Vc<OptionIssueSource> {
523 Vc::cell(Some(self.source))
524 }
525}