|
| 1 | +use std::{io::Cursor, str::FromStr}; |
| 2 | + |
| 3 | +use anyhow::{bail, Context, Result}; |
| 4 | +use base64::{display::Base64Display, engine::general_purpose::STANDARD}; |
| 5 | +use image::{ |
| 6 | + codecs::{ |
| 7 | + jpeg::JpegEncoder, |
| 8 | + png::{CompressionType, PngEncoder}, |
| 9 | + webp::{WebPEncoder, WebPQuality}, |
| 10 | + }, |
| 11 | + imageops::FilterType, |
| 12 | + GenericImageView, ImageEncoder, ImageFormat, |
| 13 | +}; |
| 14 | +use mime::Mime; |
| 15 | +use serde::{Deserialize, Serialize}; |
| 16 | +use serde_with::{serde_as, DisplayFromStr}; |
| 17 | +use turbo_tasks::{debug::ValueDebugFormat, primitives::StringVc, trace::TraceRawVcs}; |
| 18 | +use turbo_tasks_fs::{FileContent, FileContentVc, FileSystemPathVc}; |
| 19 | +use turbopack_core::{ |
| 20 | + error::PrettyPrintError, |
| 21 | + ident::AssetIdentVc, |
| 22 | + issue::{Issue, IssueVc}, |
| 23 | +}; |
| 24 | + |
| 25 | +/// Small placeholder version of the image. |
| 26 | +#[derive(PartialEq, Eq, Serialize, Deserialize, TraceRawVcs, ValueDebugFormat)] |
| 27 | +pub struct BlurPlaceholder { |
| 28 | + pub data_url: String, |
| 29 | + pub width: u32, |
| 30 | + pub height: u32, |
| 31 | +} |
| 32 | + |
| 33 | +/// Gathered meta information about an image. |
| 34 | +#[serde_as] |
| 35 | +#[turbo_tasks::value] |
| 36 | +#[derive(Default)] |
| 37 | +pub struct ImageMetaData { |
| 38 | + pub width: u32, |
| 39 | + pub height: u32, |
| 40 | + #[turbo_tasks(trace_ignore, debug_ignore)] |
| 41 | + #[serde_as(as = "Option<DisplayFromStr>")] |
| 42 | + pub mime_type: Option<Mime>, |
| 43 | + pub blur_placeholder: Option<BlurPlaceholder>, |
| 44 | + placeholder_for_future_extensions: (), |
| 45 | +} |
| 46 | + |
| 47 | +/// Options for generating a blur placeholder. |
| 48 | +#[turbo_tasks::value(shared)] |
| 49 | +pub struct BlurPlaceholderOptions { |
| 50 | + pub quality: u8, |
| 51 | + pub size: u32, |
| 52 | +} |
| 53 | + |
| 54 | +fn load_image(bytes: &[u8]) -> Result<(image::DynamicImage, Option<ImageFormat>)> { |
| 55 | + let reader = image::io::Reader::new(Cursor::new(&bytes)); |
| 56 | + let reader = reader |
| 57 | + .with_guessed_format() |
| 58 | + .context("unable to determine image format from file content")?; |
| 59 | + let format = reader.format(); |
| 60 | + let image = reader.decode().context("unable to decode image data")?; |
| 61 | + Ok((image, format)) |
| 62 | +} |
| 63 | + |
| 64 | +fn compute_blur_data( |
| 65 | + image: image::DynamicImage, |
| 66 | + format: ImageFormat, |
| 67 | + options: &BlurPlaceholderOptions, |
| 68 | +) -> Result<(String, u32, u32)> { |
| 69 | + let small_image = image.resize(options.size, options.size, FilterType::Triangle); |
| 70 | + let mut buf = Vec::new(); |
| 71 | + let blur_width = small_image.width(); |
| 72 | + let blur_height = small_image.height(); |
| 73 | + let url = match format { |
| 74 | + ImageFormat::Png => { |
| 75 | + PngEncoder::new_with_quality( |
| 76 | + &mut buf, |
| 77 | + CompressionType::Best, |
| 78 | + image::codecs::png::FilterType::NoFilter, |
| 79 | + ) |
| 80 | + .write_image( |
| 81 | + small_image.as_bytes(), |
| 82 | + blur_width, |
| 83 | + blur_height, |
| 84 | + small_image.color(), |
| 85 | + )?; |
| 86 | + format!( |
| 87 | + "data:image/png;base64,{}", |
| 88 | + Base64Display::new(&buf, &STANDARD) |
| 89 | + ) |
| 90 | + } |
| 91 | + ImageFormat::Jpeg => { |
| 92 | + JpegEncoder::new_with_quality(&mut buf, options.quality).write_image( |
| 93 | + small_image.as_bytes(), |
| 94 | + blur_width, |
| 95 | + blur_height, |
| 96 | + small_image.color(), |
| 97 | + )?; |
| 98 | + format!( |
| 99 | + "data:image/jpeg;base64,{}", |
| 100 | + Base64Display::new(&buf, &STANDARD) |
| 101 | + ) |
| 102 | + } |
| 103 | + ImageFormat::WebP => { |
| 104 | + WebPEncoder::new_with_quality(&mut buf, WebPQuality::lossy(options.quality)) |
| 105 | + .write_image( |
| 106 | + small_image.as_bytes(), |
| 107 | + blur_width, |
| 108 | + blur_height, |
| 109 | + small_image.color(), |
| 110 | + )?; |
| 111 | + format!( |
| 112 | + "data:image/webp;base64,{}", |
| 113 | + Base64Display::new(&buf, &STANDARD) |
| 114 | + ) |
| 115 | + } |
| 116 | + #[cfg(feature = "avif")] |
| 117 | + ImageFormat::Avif => { |
| 118 | + use image::codecs::avif::AvifEncoder; |
| 119 | + AvifEncoder::new_with_speed_quality(&mut buf, 6, options.quality).write_image( |
| 120 | + small_image.as_bytes(), |
| 121 | + blur_width, |
| 122 | + blur_height, |
| 123 | + small_image.color(), |
| 124 | + )?; |
| 125 | + format!( |
| 126 | + "data:image/avif;base64,{}", |
| 127 | + Base64Display::new(&buf, &STANDARD) |
| 128 | + ) |
| 129 | + } |
| 130 | + _ => unreachable!(), |
| 131 | + }; |
| 132 | + |
| 133 | + Ok((url, blur_width, blur_height)) |
| 134 | +} |
| 135 | + |
| 136 | +fn image_format_to_mime_type(format: ImageFormat) -> Result<Option<Mime>> { |
| 137 | + Ok(match format { |
| 138 | + ImageFormat::Png => Some(mime::IMAGE_PNG), |
| 139 | + ImageFormat::Jpeg => Some(mime::IMAGE_JPEG), |
| 140 | + ImageFormat::WebP => Some(Mime::from_str("image/webp")?), |
| 141 | + ImageFormat::Avif => Some(Mime::from_str("image/avif")?), |
| 142 | + ImageFormat::Bmp => Some(mime::IMAGE_BMP), |
| 143 | + ImageFormat::Dds => Some(Mime::from_str("image/vnd-ms.dds")?), |
| 144 | + ImageFormat::Farbfeld => Some(mime::APPLICATION_OCTET_STREAM), |
| 145 | + ImageFormat::Gif => Some(mime::IMAGE_GIF), |
| 146 | + ImageFormat::Hdr => Some(Mime::from_str("image/vnd.radiance")?), |
| 147 | + ImageFormat::Ico => Some(Mime::from_str("image/x-icon")?), |
| 148 | + ImageFormat::OpenExr => Some(Mime::from_str("image/x-exr")?), |
| 149 | + ImageFormat::Pnm => Some(Mime::from_str("image/x-portable-anymap")?), |
| 150 | + ImageFormat::Qoi => Some(mime::APPLICATION_OCTET_STREAM), |
| 151 | + ImageFormat::Tga => Some(Mime::from_str("image/x-tga")?), |
| 152 | + ImageFormat::Tiff => Some(Mime::from_str("image/tiff")?), |
| 153 | + _ => None, |
| 154 | + }) |
| 155 | +} |
| 156 | + |
| 157 | +/// Analyze an image and return meta information about it. |
| 158 | +/// Optionally computes a blur placeholder. |
| 159 | +#[turbo_tasks::function] |
| 160 | +pub async fn get_meta_data( |
| 161 | + ident: AssetIdentVc, |
| 162 | + content: FileContentVc, |
| 163 | + blur_placeholder: Option<BlurPlaceholderOptionsVc>, |
| 164 | +) -> Result<ImageMetaDataVc> { |
| 165 | + let FileContent::Content(content) = &*content.await? else { |
| 166 | + bail!("Input image not found"); |
| 167 | + }; |
| 168 | + let bytes = content.content().to_bytes()?; |
| 169 | + let (image, format) = match load_image(&bytes) { |
| 170 | + Ok(r) => r, |
| 171 | + Err(err) => { |
| 172 | + ImageProcessingIssue { |
| 173 | + path: ident.path(), |
| 174 | + message: StringVc::cell(format!("{}", PrettyPrintError(&err))), |
| 175 | + } |
| 176 | + .cell() |
| 177 | + .as_issue() |
| 178 | + .emit(); |
| 179 | + return Ok(ImageMetaData::default().cell()); |
| 180 | + } |
| 181 | + }; |
| 182 | + let (width, height) = image.dimensions(); |
| 183 | + let blur_placeholder = if let Some(blur_placeholder) = blur_placeholder { |
| 184 | + if matches!( |
| 185 | + format, |
| 186 | + // list should match next/client/image.tsx |
| 187 | + Some(ImageFormat::Png) |
| 188 | + | Some(ImageFormat::Jpeg) |
| 189 | + | Some(ImageFormat::WebP) |
| 190 | + | Some(ImageFormat::Avif) |
| 191 | + ) { |
| 192 | + match compute_blur_data(image, format.unwrap(), &*blur_placeholder.await?) |
| 193 | + .context("unable to compute blur placeholder") |
| 194 | + { |
| 195 | + Ok((url, blur_width, blur_height)) => Some(BlurPlaceholder { |
| 196 | + data_url: url, |
| 197 | + width: blur_width, |
| 198 | + height: blur_height, |
| 199 | + }), |
| 200 | + Err(err) => { |
| 201 | + ImageProcessingIssue { |
| 202 | + path: ident.path(), |
| 203 | + message: StringVc::cell(format!("{}", PrettyPrintError(&err))), |
| 204 | + } |
| 205 | + .cell() |
| 206 | + .as_issue() |
| 207 | + .emit(); |
| 208 | + None |
| 209 | + } |
| 210 | + } |
| 211 | + } else { |
| 212 | + None |
| 213 | + } |
| 214 | + } else { |
| 215 | + None |
| 216 | + }; |
| 217 | + |
| 218 | + Ok(ImageMetaData { |
| 219 | + width, |
| 220 | + height, |
| 221 | + mime_type: if let Some(format) = format { |
| 222 | + image_format_to_mime_type(format)? |
| 223 | + } else { |
| 224 | + None |
| 225 | + }, |
| 226 | + blur_placeholder, |
| 227 | + placeholder_for_future_extensions: (), |
| 228 | + } |
| 229 | + .cell()) |
| 230 | +} |
| 231 | + |
| 232 | +#[turbo_tasks::value] |
| 233 | +struct ImageProcessingIssue { |
| 234 | + path: FileSystemPathVc, |
| 235 | + message: StringVc, |
| 236 | +} |
| 237 | + |
| 238 | +#[turbo_tasks::value_impl] |
| 239 | +impl Issue for ImageProcessingIssue { |
| 240 | + #[turbo_tasks::function] |
| 241 | + fn context(&self) -> FileSystemPathVc { |
| 242 | + self.path |
| 243 | + } |
| 244 | + #[turbo_tasks::function] |
| 245 | + fn category(&self) -> StringVc { |
| 246 | + StringVc::cell("image".to_string()) |
| 247 | + } |
| 248 | + #[turbo_tasks::function] |
| 249 | + fn title(&self) -> StringVc { |
| 250 | + StringVc::cell("Processing image failed".to_string()) |
| 251 | + } |
| 252 | + #[turbo_tasks::function] |
| 253 | + fn description(&self) -> StringVc { |
| 254 | + self.message |
| 255 | + } |
| 256 | +} |
0 commit comments