1use async_trait::async_trait;
2use image::ImageReader;
3use itertools::Itertools;
4use serde::{Deserialize, Serialize};
5use std::{
6 io::{Cursor, Write},
7 path::Path,
8 sync::Arc,
9};
10
11use crate::prelude::*;
12use crate::{descriptor::FileIdGeneratorTask, preset, store, variant};
13use cloudillo_core::scheduler::{Task, TaskId};
14use cloudillo_types::blob_adapter;
15use cloudillo_types::meta_adapter;
16use cloudillo_types::types::TnId;
17
18pub struct ResizeResult {
20 pub bytes: Box<[u8]>,
21 pub width: u32,
22 pub height: u32,
23}
24
25#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
26pub enum ImageFormat {
27 #[serde(rename = "avif")]
28 Avif,
29 #[serde(rename = "webp")]
30 Webp,
31 #[serde(rename = "jpeg")]
32 Jpeg,
33 #[serde(rename = "png")]
34 Png,
35}
36
37impl AsRef<str> for ImageFormat {
38 fn as_ref(&self) -> &str {
39 match self {
40 ImageFormat::Avif => "avif",
41 ImageFormat::Webp => "webp",
42 ImageFormat::Jpeg => "jpeg",
43 ImageFormat::Png => "png",
44 }
45 }
46}
47
48impl std::str::FromStr for ImageFormat {
49 type Err = Error;
50 fn from_str(s: &str) -> Result<Self, Error> {
51 Ok(match s {
52 "avif" => ImageFormat::Avif,
53 "webp" => ImageFormat::Webp,
54 "jpeg" => ImageFormat::Jpeg,
55 "png" => ImageFormat::Png,
56 _ => return Err(Error::ValidationError(format!("unsupported image format: {}", s))),
57 })
58 }
59}
60
61fn resize_image_sync<'a>(
63 orig_buf: impl AsRef<[u8]> + 'a,
64 format: ImageFormat,
65 resize: (u32, u32),
66) -> Result<ResizeResult, image::error::ImageError> {
67 let now = std::time::Instant::now();
68 let original = ImageReader::new(Cursor::new(&orig_buf.as_ref()))
69 .with_guessed_format()?
70 .decode()?;
71 debug!("decoded [{:.2}ms]", now.elapsed().as_millis());
72
73 let now = std::time::Instant::now();
74 let resized = original.resize(resize.0, resize.1, image::imageops::FilterType::Lanczos3);
75 let actual_width = resized.width();
76 let actual_height = resized.height();
77 debug!("resized [{:.2}ms]", now.elapsed().as_millis());
78
79 let mut output = Cursor::new(Vec::new());
80 let now = std::time::Instant::now();
81
82 match format {
83 ImageFormat::Avif => {
84 let encoder =
85 image::codecs::avif::AvifEncoder::new_with_speed_quality(&mut output, 4, 80)
86 .with_num_threads(Some(1));
87 resized.write_with_encoder(encoder)?;
88 }
89 ImageFormat::Webp => {
90 let rgba = resized.to_rgba8();
92 let encoder = webp::Encoder::from_rgba(rgba.as_raw(), actual_width, actual_height);
93 let webp_data = encoder.encode(80.0); output.get_mut().write_all(&webp_data)?;
95 }
96 ImageFormat::Jpeg => {
97 let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 95);
98 resized.write_with_encoder(encoder)?;
99 }
100 ImageFormat::Png => {
101 let encoder = image::codecs::png::PngEncoder::new(&mut output);
102 resized.write_with_encoder(encoder)?;
103 }
104 };
105 debug!("written [{:.2}ms]", now.elapsed().as_millis());
106 Ok(ResizeResult {
107 bytes: output.into_inner().into(),
108 width: actual_width,
109 height: actual_height,
110 })
111}
112
113pub async fn resize_image(
114 app: App,
115 orig_buf: Vec<u8>,
116 format: ImageFormat,
117 resize: (u32, u32),
118) -> Result<ResizeResult, image::error::ImageError> {
119 app.worker
120 .run_immed(move || {
121 info!("Resizing image");
122 resize_image_sync(orig_buf, format, resize)
123 })
124 .await
125 .map_err(|e| image::error::ImageError::IoError(std::io::Error::other(e.to_string())))?
126}
127
128pub async fn get_image_dimensions(buf: &[u8]) -> Result<(u32, u32), image::error::ImageError> {
129 let now = std::time::Instant::now();
130 let dim = ImageReader::new(Cursor::new(&buf)).with_guessed_format()?.into_dimensions()?;
131 debug!("dimensions read in [{:.2}ms]", now.elapsed().as_millis());
132 Ok(dim)
133}
134
135pub fn detect_image_type(buf: &[u8]) -> Option<String> {
137 let reader = ImageReader::new(Cursor::new(buf));
138 let format = reader.with_guessed_format().ok()?.format()?;
139
140 Some(match format {
141 image::ImageFormat::Jpeg => "image/jpeg".to_string(),
142 image::ImageFormat::Png => "image/png".to_string(),
143 image::ImageFormat::WebP => "image/webp".to_string(),
144 image::ImageFormat::Avif => "image/avif".to_string(),
145 _ => return None,
146 })
147}
148
149pub struct ImageVariantResult {
151 pub thumbnail_variant_id: String,
153 pub file_id_task: TaskId,
155 pub dim: (u32, u32),
157}
158
159pub async fn generate_image_variants(
170 app: &App,
171 tn_id: TnId,
172 f_id: u64,
173 bytes: &[u8],
174 preset: &preset::FilePreset,
175) -> ClResult<ImageVariantResult> {
176 let thumbnail_format_str = app
178 .settings
179 .get_string(tn_id, "file.thumbnail_format")
180 .await
181 .unwrap_or_else(|_| "webp".to_string());
182 let thumbnail_format: ImageFormat = thumbnail_format_str.parse().unwrap_or(ImageFormat::Webp);
183
184 let image_format_str = app
185 .settings
186 .get_string(tn_id, "file.image_format")
187 .await
188 .unwrap_or_else(|_| "webp".to_string());
189 let image_format: ImageFormat = image_format_str.parse().unwrap_or(ImageFormat::Avif);
190
191 let max_quality_str = app
193 .settings
194 .get_string(tn_id, "file.max_generate_variant")
195 .await
196 .unwrap_or_else(|_| "hd".to_string());
197 let max_quality =
198 variant::parse_quality(&max_quality_str).unwrap_or(variant::VariantQuality::High);
199
200 let orig_format = detect_image_type(bytes)
202 .map(|ct| match ct.as_str() {
203 "image/jpeg" => "jpeg",
204 "image/png" => "png",
205 "image/webp" => "webp",
206 "image/avif" => "avif",
207 _ => "jpeg",
208 })
209 .unwrap_or("jpeg");
210
211 let orig_dim = get_image_dimensions(bytes).await?;
213 info!("Original image dimensions: {}x{}", orig_dim.0, orig_dim.1);
214
215 let (orig_variant_id, orig_available) = if preset.store_original {
217 let variant_id =
219 store::create_blob_buf(app, tn_id, bytes, blob_adapter::CreateBlobOptions::default())
220 .await?;
221 (variant_id, true)
222 } else {
223 use cloudillo_types::hasher;
225 let variant_id = hasher::hash("b", bytes);
226 (variant_id, false)
227 };
228
229 app.meta_adapter
231 .create_file_variant(
232 tn_id,
233 f_id,
234 meta_adapter::FileVariant {
235 variant_id: orig_variant_id.as_ref(),
236 variant: "orig",
237 format: orig_format,
238 resolution: orig_dim,
239 size: bytes.len() as u64,
240 available: orig_available,
241 duration: None,
242 bitrate: None,
243 page_count: None,
244 },
245 )
246 .await?;
247
248 let orig_file = app.opts.tmp_dir.join::<&str>(&orig_variant_id);
250 tokio::fs::write(&orig_file, bytes).await?;
251
252 let thumbnail_variant = preset.thumbnail_variant.as_deref().unwrap_or("vis.tn");
254 let thumbnail_tier = preset::get_image_tier(thumbnail_variant);
255
256 let tn_format = thumbnail_tier.and_then(|t| t.format).unwrap_or(thumbnail_format);
258 let tn_max_dim = thumbnail_tier.map(|t| t.max_dim).unwrap_or(256);
259
260 let resized_tn =
262 resize_image(app.clone(), bytes.to_vec(), tn_format, (tn_max_dim, tn_max_dim)).await?;
263
264 let thumbnail_variant_id = store::create_blob_buf(
265 app,
266 tn_id,
267 &resized_tn.bytes,
268 blob_adapter::CreateBlobOptions::default(),
269 )
270 .await?;
271
272 app.meta_adapter
273 .create_file_variant(
274 tn_id,
275 f_id,
276 meta_adapter::FileVariant {
277 variant_id: thumbnail_variant_id.as_ref(),
278 variant: thumbnail_variant,
279 format: tn_format.as_ref(),
280 resolution: (resized_tn.width, resized_tn.height),
281 size: resized_tn.bytes.len() as u64,
282 available: true,
283 duration: None,
284 bitrate: None,
285 page_count: None,
286 },
287 )
288 .await?;
289
290 const SKIP_THRESHOLD: f32 = 0.10; let original_max = orig_dim.0.max(orig_dim.1) as f32;
293 let mut variant_task_ids = Vec::new();
294 let mut last_created_size = tn_max_dim as f32;
295
296 for variant_name in &preset.image_variants {
298 if variant_name == thumbnail_variant {
300 continue;
301 }
302 if let Some(parsed) = variant::Variant::parse(variant_name) {
304 if parsed.quality > max_quality {
305 info!(
306 "Skipping variant {} - exceeds max_generate_variant {}",
307 variant_name, max_quality_str
308 );
309 continue;
310 }
311 }
312 if let Some(tier) = preset::get_image_tier(variant_name) {
313 let variant_bbox_f = tier.max_dim as f32;
314
315 let actual_size = variant_bbox_f.min(original_max);
317
318 let min_required_increase = last_created_size * (1.0 + SKIP_THRESHOLD);
320 if actual_size > min_required_increase {
321 let variant_format = tier.format.unwrap_or(image_format);
323
324 info!(
325 "Creating variant {} with bounding box {}x{} (capped from {})",
326 variant_name, actual_size as u32, actual_size as u32, tier.max_dim
327 );
328
329 let task = ImageResizerTask::new(
330 tn_id,
331 f_id,
332 orig_file.clone(),
333 variant_name.clone(),
334 variant_format,
335 (actual_size as u32, actual_size as u32),
336 );
337 let task_id = app.scheduler.add(task).await?;
338 variant_task_ids.push(task_id);
339 last_created_size = actual_size;
340 } else {
341 info!(
342 "Skipping variant {} - would be {}, only {:.0}% larger than last ({})",
343 variant_name,
344 actual_size as u32,
345 (actual_size / last_created_size - 1.0) * 100.0,
346 last_created_size as u32
347 );
348 }
349 }
350 }
351
352 let mut builder = app
354 .scheduler
355 .task(FileIdGeneratorTask::new(tn_id, f_id))
356 .key(format!("{},{}", tn_id, f_id));
357 if !variant_task_ids.is_empty() {
358 builder = builder.depend_on(variant_task_ids);
359 }
360 let file_id_task = builder.schedule().await?;
361
362 Ok(ImageVariantResult {
363 thumbnail_variant_id: thumbnail_variant_id.into(),
364 file_id_task,
365 dim: orig_dim,
366 })
367}
368
369#[derive(Debug, Serialize, Deserialize)]
372pub struct ImageResizerTask {
373 tn_id: TnId,
374 f_id: u64,
375 variant: Box<str>,
376 format: ImageFormat,
377 path: Box<Path>,
378 res: (u32, u32),
379}
380
381impl ImageResizerTask {
382 pub fn new(
383 tn_id: TnId,
384 f_id: u64,
385 path: impl Into<Box<Path>>,
386 variant: impl Into<Box<str>>,
387 format: ImageFormat,
388 res: (u32, u32),
389 ) -> Arc<Self> {
390 Arc::new(Self { tn_id, f_id, path: path.into(), format, variant: variant.into(), res })
391 }
392}
393
394#[async_trait]
395impl Task<App> for ImageResizerTask {
396 fn kind() -> &'static str {
397 "image.resize"
398 }
399 fn kind_of(&self) -> &'static str {
400 Self::kind()
401 }
402
403 fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
404 let (tn_id, f_id, format, variant, x_res, y_res, path) =
405 ctx.split(',').collect_tuple().ok_or(Error::Parse)?;
406 let format: ImageFormat = format.parse()?;
407 let task = ImageResizerTask::new(
408 TnId(tn_id.parse()?),
409 f_id.parse()?,
410 Box::from(Path::new(path)),
411 variant,
412 format,
413 (x_res.parse()?, y_res.parse()?),
414 );
415 Ok(task)
416 }
417
418 fn serialize(&self) -> String {
419 let format: &str = self.format.as_ref();
420
421 format!(
422 "{},{},{},{},{},{},{}",
423 self.tn_id,
424 self.f_id,
425 format,
426 self.variant,
427 self.res.0,
428 self.res.1,
429 self.path.to_string_lossy()
430 )
431 }
432
433 async fn run(&self, app: &App) -> ClResult<()> {
434 info!("Running task image.resize {:?} {:?}", self.path, self.res);
435 let bytes = tokio::fs::read(self.path.clone()).await?;
436 let res = self.res;
437 let format = self.format;
438 let resize_result = app
439 .worker
440 .try_run(move || -> ClResult<_> { Ok(resize_image_sync(bytes, format, res)?) })
441 .await?;
442 info!(
443 "Finished task image.resize {:?} {} ({}x{})",
444 self.path,
445 resize_result.bytes.len(),
446 resize_result.width,
447 resize_result.height
448 );
449
450 let actual_dimensions = (resize_result.width, resize_result.height);
451 let variant_id = store::create_blob_buf(
452 app,
453 self.tn_id,
454 &resize_result.bytes,
455 blob_adapter::CreateBlobOptions::default(),
456 )
457 .await?;
458 app.meta_adapter
459 .create_file_variant(
460 self.tn_id,
461 self.f_id,
462 meta_adapter::FileVariant {
463 variant_id: &variant_id,
464 variant: &self.variant,
465 format: match self.format {
466 ImageFormat::Avif => "avif",
467 ImageFormat::Webp => "webp",
468 ImageFormat::Jpeg => "jpeg",
469 ImageFormat::Png => "png",
470 },
471 resolution: actual_dimensions,
472 size: resize_result.bytes.len() as u64,
473 available: true,
474 duration: None,
475 bitrate: None,
476 page_count: None,
477 },
478 )
479 .await?;
480 Ok(())
481 }
482}
483
484