Skip to main content

cloudillo_file/
image.rs

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
18/// Result of image resizing: encoded bytes and actual dimensions
19pub 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
61// Sync image resizer
62fn 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			// Use webp crate for lossy encoding with quality 80
91			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); // Quality 0-100
94			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
135/// Detect image type from binary data and return MIME type
136pub 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
149/// Result of image variant generation
150pub struct ImageVariantResult {
151	/// Variant ID for the thumbnail variant (created synchronously)
152	pub thumbnail_variant_id: String,
153	/// TaskId of the FileIdGeneratorTask (for chaining dependencies)
154	pub file_id_task: TaskId,
155	/// Original image dimensions (width, height)
156	pub dim: (u32, u32),
157}
158
159/// Generate image variants based on preset configuration.
160///
161/// This is the main helper function for image processing. It:
162/// 1. Creates "orig" variant record (blob stored only if preset.store_original is true)
163/// 2. Creates the thumbnail variant synchronously (from preset.thumbnail_variant)
164/// 3. Schedules tasks for remaining variants
165/// 4. Schedules FileIdGeneratorTask depending on all variant tasks
166///
167/// Returns the thumbnail_variant_id for immediate response and the file_id_task
168/// for chaining additional dependent tasks.
169pub 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	// Read format settings
177	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	// Read max_generate_variant setting
192	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	// Detect original format from content
201	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	// Get original image dimensions
212	let orig_dim = get_image_dimensions(bytes).await?;
213	info!("Original image dimensions: {}x{}", orig_dim.0, orig_dim.1);
214
215	// Conditionally store original blob based on preset
216	let (orig_variant_id, orig_available) = if preset.store_original {
217		// Store original blob
218		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		// Don't store blob, but compute content hash for the variant_id
224		use cloudillo_types::hasher;
225		let variant_id = hasher::hash("b", bytes);
226		(variant_id, false)
227	};
228
229	// Create "orig" variant record (always created, but available depends on store_original)
230	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	// Save original to temp file for async variant tasks
249	let orig_file = app.opts.tmp_dir.join::<&str>(&orig_variant_id);
250	tokio::fs::write(&orig_file, bytes).await?;
251
252	// Determine thumbnail variant to create synchronously
253	let thumbnail_variant = preset.thumbnail_variant.as_deref().unwrap_or("vis.tn");
254	let thumbnail_tier = preset::get_image_tier(thumbnail_variant);
255
256	// Determine format for thumbnail variant
257	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	// Generate thumbnail variant synchronously
261	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	// Smart variant creation: skip creating variants if image is too small or too close in size
291	const SKIP_THRESHOLD: f32 = 0.10; // Skip variant if it's less than 10% larger than previous
292	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	// Create visual variants from preset's image_variants
297	for variant_name in &preset.image_variants {
298		// Skip the thumbnail variant (already created synchronously)
299		if variant_name == thumbnail_variant {
300			continue;
301		}
302		// Skip variants exceeding max_generate_variant setting
303		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			// Determine actual size: cap at original to avoid upscaling
316			let actual_size = variant_bbox_f.min(original_max);
317
318			// Check if size is significantly different from last created variant
319			let min_required_increase = last_created_size * (1.0 + SKIP_THRESHOLD);
320			if actual_size > min_required_increase {
321				// Determine format for this variant (tier override or setting)
322				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	// FileIdGeneratorTask depends on all created variant tasks
353	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/// Image resizer Task
370///
371#[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// vim: ts=4