use crate::media::MediaTools; use anyhow::{anyhow, Result}; use fast_image_resize as fir; use fast_image_resize::IntoImageView; use image::codecs::jpeg::JpegEncoder; use image::ImageDecoder; use std::path::{Path, PathBuf}; pub const THUMB_SIZE: u32 = 320; pub struct GeneratedThumbnail { pub path: PathBuf, pub width: Option, pub height: Option, /// Dominant-color palette `(r, g, b, weight)` sampled while resizing. Empty /// when the thumbnail already existed (the color backfill handles those). pub palette: Vec<(u8, u8, u8, f32)>, } pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result { let path_str = image_path.to_string_lossy(); let out_path = thumb_path(cache_dir, &path_str); let original_dimensions = image::image_dimensions(image_path) .ok() .map(|(width, height)| (Some(width as i64), Some(height as i64))) .unwrap_or((None, None)); if out_path.exists() { return Ok(GeneratedThumbnail { path: out_path, width: original_dimensions.0, height: original_dimensions.1, palette: Vec::new(), }); } let img = decode_for_thumbnail(image_path)?; // RGB8 throughout: JPEG output has no alpha, so decoding/resizing RGBA // only to flatten at encode time wastes a third of the bandwidth. let src = image::DynamicImage::ImageRgb8(img.into_rgb8()); let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE); let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap()); let mut resizer = fir::Resizer::new(); let options = fir::ResizeOptions::new() .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom)); resizer.resize(&src, &mut dst, Some(&options))?; let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec()) .ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?; // Sample the dominant-color palette from the resized buffer before encoding. let palette = crate::color::extract_palette(&thumb, crate::color::PALETTE_SIZE) .into_iter() .map(|color| (color.r, color.g, color.b, color.weight)) .collect(); if let Some(parent) = out_path.parent() { std::fs::create_dir_all(parent)?; } let mut output_file = std::fs::File::create(&out_path)?; let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82); encoder.encode_image(&thumb)?; Ok(GeneratedThumbnail { path: out_path, width: original_dimensions.0, height: original_dimensions.1, palette, }) } /// Decodes an image for thumbnailing, with EXIF orientation already applied. fn decode_for_thumbnail(image_path: &Path) -> Result { decode_image_scaled(image_path, THUMB_SIZE, false) } /// Decodes an image at reduced resolution, with EXIF orientation applied. /// /// JPEGs take a fast path that decodes at reduced resolution (DCT scaling), /// which skips most of the IDCT/upsampling work for large photos. Anything /// that path can't handle falls back to the `image` crate at full size. /// /// `cover` selects which side must stay at or above `target`: `false` keeps /// the longest side (for aspect-fit consumers), `true` keeps the shortest /// side (for fill-crop consumers like the CLIP preprocessor). pub fn decode_image_scaled( image_path: &Path, target: u32, cover: bool, ) -> Result { if is_jpeg(image_path) { if let Some(img) = decode_jpeg_scaled(image_path, target, cover) { return Ok(img); } } let reader = image::ImageReader::open(image_path)?.with_guessed_format()?; let mut decoder = reader.into_decoder()?; let orientation = decoder.orientation()?; let mut img = image::DynamicImage::from_decoder(decoder)?; img.apply_orientation(orientation); Ok(img) } fn is_jpeg(path: &Path) -> bool { path.extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg")) } /// Decodes a JPEG at the smallest DCT scale that still covers `target`. /// Returns `None` on any failure (corrupt file, CMYK without conversion /// support, etc.) so the caller can fall back to the generic decoder. /// mozjpeg reports errors by panicking, hence the `catch_unwind`. fn decode_jpeg_scaled(image_path: &Path, target: u32, cover: bool) -> Option { let path = image_path.to_path_buf(); let decoded = std::panic::catch_unwind(move || -> std::io::Result<(Vec, u32, u32)> { let mut decompress = mozjpeg::Decompress::new_path(&path)?; let (width, height) = decompress.size(); let reference = if cover { width.min(height) } else { width.max(height) }; decompress.scale(scale_numerator(reference, target)); let mut started = decompress.rgb()?; let (out_width, out_height) = (started.width() as u32, started.height() as u32); let pixels = started.read_scanlines::()?; started.finish()?; Ok((pixels, out_width, out_height)) }) .ok()? .ok()?; let (pixels, width, height) = decoded; let buffer = image::RgbImage::from_raw(width, height, pixels)?; let mut img = image::DynamicImage::ImageRgb8(buffer); if let Some(orientation) = exif_orientation(image_path) { img.apply_orientation(orientation); } Some(img) } /// Smallest numerator (of /8) that keeps `reference` at or above `target`, /// so the subsequent resize never upscales. fn scale_numerator(reference: usize, target: u32) -> u8 { for numerator in 1..=8u8 { if reference * numerator as usize / 8 >= target as usize { return numerator; } } 8 } fn exif_orientation(path: &Path) -> Option { let file = std::fs::File::open(path).ok()?; let mut reader = std::io::BufReader::new(file); let exif = exif::Reader::new().read_from_container(&mut reader).ok()?; let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?; let value = field.value.get_uint(0)?; image::metadata::Orientation::from_exif(value as u8) } pub fn generate_video_thumbnail( tools: &MediaTools, video_path: &Path, cache_dir: &Path, ) -> Result { let path_str = video_path.to_string_lossy(); let out_path = video_poster_path(cache_dir, &path_str); if out_path.exists() { return Ok(GeneratedThumbnail { path: out_path, width: None, height: None, palette: Vec::new(), }); } if let Some(parent) = out_path.parent() { std::fs::create_dir_all(parent)?; } let output_path = out_path.to_string_lossy().into_owned(); let attempts: [&[&str]; 3] = [ &[ "-y", "-threads", "2", "-ss", "00:00:00.000", "-i", path_str.as_ref(), "-frames:v", "1", "-vf", "scale=320:-1:force_original_aspect_ratio=decrease", "-q:v", "4", &output_path, ], &[ "-y", "-threads", "2", "-ss", "00:00:00.250", "-i", path_str.as_ref(), "-frames:v", "1", "-vf", "scale=320:-1:force_original_aspect_ratio=decrease", "-q:v", "4", &output_path, ], &[ "-y", "-threads", "2", "-i", path_str.as_ref(), "-frames:v", "1", "-vf", "scale=320:-1:force_original_aspect_ratio=decrease", "-q:v", "4", &output_path, ], ]; let mut last_error = String::new(); for args in attempts { let output = tools.ffmpeg_command().args(args).output()?; if output.status.success() && out_path.exists() { return Ok(GeneratedThumbnail { path: out_path, width: None, height: None, palette: Vec::new(), }); } last_error = String::from_utf8_lossy(&output.stderr).to_string(); } Err(anyhow!( "ffmpeg failed generating poster for {path_str}: {last_error}" )) } pub fn generate_avif_thumbnail( tools: &MediaTools, image_path: &Path, cache_dir: &Path, ) -> Result { let path_str = image_path.to_string_lossy(); let out_path = thumb_path(cache_dir, &path_str); let original_dimensions = ffprobe_dimensions(tools, image_path); if out_path.exists() { return Ok(GeneratedThumbnail { path: out_path, width: original_dimensions.0, height: original_dimensions.1, palette: Vec::new(), }); } if let Some(parent) = out_path.parent() { std::fs::create_dir_all(parent)?; } let output_path = out_path.to_string_lossy().into_owned(); let output = tools .ffmpeg_command() .args([ "-y", "-threads", "2", "-i", path_str.as_ref(), "-frames:v", "1", "-vf", "scale=320:-1:force_original_aspect_ratio=decrease", "-q:v", "4", &output_path, ]) .output()?; if output.status.success() && out_path.exists() { // AVIF is decoded by FFmpeg, so there's no in-memory RGB buffer here; // sample the palette by reading the JPEG thumbnail we just wrote. let palette = crate::color::extract_palette_from_file(&out_path, crate::color::PALETTE_SIZE) .unwrap_or_default() .into_iter() .map(|color| (color.r, color.g, color.b, color.weight)) .collect(); return Ok(GeneratedThumbnail { path: out_path, width: original_dimensions.0, height: original_dimensions.1, palette, }); } Err(anyhow!( "ffmpeg failed generating AVIF thumbnail for {path_str}: {}", String::from_utf8_lossy(&output.stderr) )) } fn ffprobe_dimensions(tools: &MediaTools, image_path: &Path) -> (Option, Option) { let output = tools .ffprobe_command() .args([ "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", &image_path.to_string_lossy(), ]) .output(); let Ok(output) = output else { return (None, None); }; if !output.status.success() { return (None, None); } let stdout = String::from_utf8_lossy(&output.stdout); let mut parts = stdout.trim().split('x'); let width = parts.next().and_then(|part| part.parse::().ok()); let height = parts.next().and_then(|part| part.parse::().ok()); (width, height) } pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf { thumb_path_with_ext(cache_dir, image_path, "jpg") } pub fn video_poster_path(cache_dir: &Path, video_path: &str) -> PathBuf { thumb_path_with_ext(cache_dir, video_path, "jpg") } fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf { let hash = hash_path(input_path); cache_dir.join(format!("{hash}.{extension}")) } fn hash_path(s: &str) -> String { format!("{:016x}", xxhash_rust::xxh3::xxh3_64(s.as_bytes())) } fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) { if width <= max_size && height <= max_size { return (width.max(1), height.max(1)); } if width >= height { let scaled_height = ((height as f64 / width as f64) * max_size as f64).round() as u32; (max_size, scaled_height.max(1)) } else { let scaled_width = ((width as f64 / height as f64) * max_size as f64).round() as u32; (scaled_width.max(1), max_size) } } #[cfg(test)] mod tests { use super::*; #[test] fn fit_dimensions_preserves_aspect_ratio_within_max() { // Already small enough: unchanged. assert_eq!(fit_dimensions(100, 50, THUMB_SIZE), (100, 50)); // Landscape and portrait scale to the max on their long edge. assert_eq!(fit_dimensions(6400, 3200, THUMB_SIZE), (320, 160)); assert_eq!(fit_dimensions(3200, 6400, THUMB_SIZE), (160, 320)); // Extreme ratios never collapse to zero. assert_eq!(fit_dimensions(1, 100_000, 320), (1, 320)); assert_eq!(fit_dimensions(100_000, 1, 320), (320, 1)); } #[test] fn is_jpeg_checks_extension_only() { assert!(is_jpeg(Path::new("photo.jpg"))); assert!(is_jpeg(Path::new("photo.JPEG"))); assert!(!is_jpeg(Path::new("photo.png"))); assert!(!is_jpeg(Path::new("photo"))); } #[test] fn scale_numerator_picks_smallest_sufficient() { assert_eq!(scale_numerator(6000, THUMB_SIZE), 1); assert_eq!(scale_numerator(640, THUMB_SIZE), 4); assert_eq!(scale_numerator(320, THUMB_SIZE), 8); assert_eq!(scale_numerator(100, THUMB_SIZE), 8); // Cover mode reference: shortest side must reach 224 for CLIP. assert_eq!(scale_numerator(1200, 224), 2); } #[test] fn jpeg_fast_path_decodes_at_reduced_resolution() { let dir = std::env::temp_dir().join("phokus-thumb-test"); std::fs::create_dir_all(&dir).unwrap(); let src_path = dir.join("large.jpg"); let img = image::RgbImage::from_fn(1600, 1200, |x, _| image::Rgb([(x % 256) as u8, 64, 128])); img.save(&src_path).unwrap(); // 1600 max dim -> numerator 2 -> 400x300 decode output let decoded = decode_jpeg_scaled(&src_path, THUMB_SIZE, false) .expect("fast path should handle plain JPEG"); assert_eq!((decoded.width(), decoded.height()), (400, 300)); // Cover mode: shortest side (1200) must stay >= 224 -> numerator 2 let covered = decode_jpeg_scaled(&src_path, 224, true).expect("fast path should handle plain JPEG"); assert_eq!((covered.width(), covered.height()), (400, 300)); let cache = dir.join("cache"); let _ = std::fs::remove_file(thumb_path(&cache, &src_path.to_string_lossy())); let result = generate_image_thumbnail(&src_path, &cache).unwrap(); assert_eq!(result.width, Some(1600)); assert_eq!(result.height, Some(1200)); let (thumb_w, thumb_h) = image::image_dimensions(&result.path).unwrap(); assert_eq!((thumb_w, thumb_h), (320, 240)); } }