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