//! Decode dispatch + thumbnail creation (M2). //! //! JPEG: `turbojpeg` (libjpeg-turbo) DCT-level shrink decode — the same //! shrink-on-load libvips wraps internally (HANDOFF §10: why turbojpeg, not //! libvips, for Android). Pipeline: //! 1. pick a scale (1/2, 1/4, 1/8) so the decode lands at >= target size; //! 2. decode RGB at that scale (peak memory = scaled size, never full-size: //! 48 MP -> 1000x752x3 = 2.3 MB at 1/8, vs 137 MB full); //! 3. apply EXIF orientation once, to the small buffer (kamadak-exif); //! 4. final fit-resize into the target box (`image::imageops`); //! 5. re-encode JPEG for the disk cache (turbojpeg `Compressor`). //! //! PNG: `image` crate (png feature), full decode + resize (not the hot //! path). Pipeline output is RGB8; the Tauri layer re-encodes it as a small //! JPEG for the on-disk cache — Chromium then decodes THAT (its async //! decode threads + image cache + eviction do the rest). use std::io::Cursor; use std::path::Path; use exif::{In, Reader, Tag}; use image::imageops; use image::imageops::FilterType; use image::{ImageBuffer, Rgb}; use turbojpeg::{Decompressor, Image, PixelFormat, ScalingFactor}; pub struct Thumb { pub rgb: Vec, pub width: u32, pub height: u32, /// Source dimensions after orientation is applied (full resolution). pub src_width: u32, pub src_height: u32, /// EXIF orientation 1..8 (1 = none). pub orientation: u8, } /// Decode a file at full (1/1) size to RGBA8. Used only for small JPEGs in the /// disk cache on a cache hit. pub fn decode_jpeg(path: &Path) -> Result { let data = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; let (rgb, w, h) = decode_jpeg_scaled(&data, ScalingFactor::ONE)?; Ok(Thumb { rgb, width: w, height: h, src_width: w, src_height: h, orientation: 1, }) } /// Decode JPEG bytes at a fixed scaling factor to RGB8. pub fn decode_jpeg_scaled(data: &[u8], scale: ScalingFactor) -> Result<(Vec, u32, u32), String> { let mut d = Decompressor::new().map_err(|e| e.to_string())?; let header = d .read_header(data) .map_err(|e| format!("jpeg header: {e}"))? .scaled(scale); let (w, h) = (header.width as u32, header.height as u32); if w == 0 || h == 0 { return Err("zero-size jpeg".into()); } let mut img = Image { pixels: vec![0u8; 3 * w as usize * h as usize], width: w as usize, pitch: 3 * w as usize, height: h as usize, format: PixelFormat::RGB, }; d.set_scaling_factor(scale).map_err(|e| e.to_string())?; d.decompress(data, img.as_deref_mut()) .map_err(|e| format!("jpeg decode: {e}"))?; Ok((img.pixels, w, h)) } /// Re-encode RGB8 as JPEG (disk-cache entries). pub fn encode_jpeg(rgb: &[u8], width: u32, height: u32, quality: u8) -> Result, String> { let img = Image { pixels: rgb.to_vec(), width: width as usize, pitch: 3 * width as usize, height: height as usize, format: PixelFormat::RGB, }; let mut c = turbojpeg::Compressor::new().map_err(|e| e.to_string())?; c.set_quality(quality as i32).map_err(|e| e.to_string())?; c.compress_to_vec(img.as_deref()).map_err(|e| e.to_string()) } /// Full pipeline: decode + orient + fit to `target` box. /// /// JPEG goes through turbojpeg (fast DCT-scaled decode). When that fails — /// the classic case is a WEBP/GIF file saved with a `.jpg` name, which /// turbojpeg rejects ("Not a JPEG file: starts with 0x52 0x49" = RIFF) — /// fall back to the format-guessing `image` crate so mislabeled files still /// render. (Files that fail BOTH are marked BROKEN upstream and never /// retried.) pub fn make_thumb(path: &Path, target: u32) -> Result { match path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()) { Some(ref ext) if ext == "jpg" || ext == "jpeg" => { thumb_jpeg(path, target).or_else(|e| { generic_decode(path, target).map_err(|ge| format!("{e}; fallback: {ge}")) }) } _ => generic_decode(path, target), } } /// Format-guessing decode (webp, gif, png, …) for non-turbojpeg files. pub fn generic_decode(path: &Path, target: u32) -> Result { let img = image::ImageReader::open(path) .map_err(|e| format!("open {}: {e}", path.display()))? .with_guessed_format() .map_err(|e| format!("format: {e}"))? .decode() .map_err(|e| format!("decode {}: {e}", path.display()))?; let rgb = img.to_rgb8(); let (w, h) = (rgb.width(), rgb.height()); Ok(fit_to_target(rgb.into_raw(), w, h, target, w, h, 1)) } fn thumb_jpeg(path: &Path, target: u32) -> Result { let data = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; let mut d = Decompressor::new().map_err(|e| e.to_string())?; let header = d.read_header(&data).map_err(|e| format!("jpeg header: {e}"))?; let (fw, fh) = (header.width as u32, header.height as u32); let orientation = exif_orientation(&data); // Post-orientation full dimensions (90/270 swap w/h). let (src_w, src_h) = if swaps_wh(orientation) { (fh, fw) } else { (fw, fh) }; let scale = pick_scale(src_w.max(src_h), target); let (rgb, w, h) = decode_jpeg_scaled(&data, scale)?; let (rgb, w, h) = apply_orientation(rgb, w, h, orientation); Ok(fit_to_target(rgb, w, h, target, src_w, src_h, orientation)) } fn thumb_png(path: &Path, target: u32) -> Result { let img = image::ImageReader::open(path) .map_err(|e| format!("open {}: {e}", path.display()))? .with_guessed_format() .map_err(|e| e.to_string())? .decode() .map_err(|e| format!("png decode: {e}"))?; let rgb = img.to_rgb8(); let (w, h) = (rgb.width(), rgb.height()); Ok(fit_to_target(rgb.into_raw(), w, h, target, w, h, 1)) } /// Largest shrink (1/8, 1/4, 1/2) whose result is still >= `target` on the /// longest side; never upscales. fn pick_scale(max_dim: u32, target: u32) -> ScalingFactor { for denom in [8u32, 4, 2] { if max_dim / denom >= target { return ScalingFactor::new(1, denom as usize); } } ScalingFactor::ONE } fn swaps_wh(o: u8) -> bool { matches!(o, 5 | 6 | 7 | 8) } /// EXIF orientation (1..8) from the file bytes; 1 when absent/invalid. pub fn exif_orientation(data: &[u8]) -> u8 { let mut reader = Cursor::new(data); let Ok(exif) = Reader::new().read_from_container(&mut reader) else { return 1; }; exif.get_field(Tag::Orientation, In::PRIMARY) .and_then(|f| f.value.get_uint(0)) .filter(|v| (1..=8).contains(v)) .unwrap_or(1) as u8 } /// Apply one of the 8 EXIF orientations to an RGB8 buffer (in place semantics: /// returns a new buffer). pub fn apply_orientation(rgb: Vec, w: u32, h: u32, orientation: u8) -> (Vec, u32, u32) { // Output dims: orientations 5-8 swap width/height. let (ow, oh) = if swaps_wh(orientation) { (h, w) } else { (w, h) }; let map = |x: usize, y: usize| -> (usize, usize) { let (w, h) = (w as usize, h as usize); match orientation { 1 => (x, y), // normal 2 => (w - 1 - x, y), // mirror horizontal 3 => (w - 1 - x, h - 1 - y), // 180 4 => (x, h - 1 - y), // mirror vertical 5 => (y, x), // transpose (main diagonal) 6 => (h - 1 - y, x), // 90 CW 7 => (h - 1 - y, w - 1 - x), // transverse (anti-diagonal) 8 => (y, w - 1 - x), // 90 CCW _ => (x, y), } }; let mut out = vec![0u8; 3 * ow as usize * oh as usize]; for y in 0..h as usize { for x in 0..w as usize { let s = 3 * (y * w as usize + x); let (dx, dy) = map(x, y); let d = 3 * (dy * ow as usize + dx); out[d..d + 3].copy_from_slice(&rgb[s..s + 3]); } } (out, ow, oh) } /// Fit into the `target` box (aspect preserved, no upscaling). fn fit_to_target( rgb: Vec, w: u32, h: u32, target: u32, src_w: u32, src_h: u32, orientation: u8, ) -> Thumb { let (w, h, rgb) = if w.max(h) > target { let (nw, nh) = if w >= h { (target, (h as f64 * target as f64 / w as f64).max(1.0).round() as u32) } else { ((w as f64 * target as f64 / h as f64).max(1.0).round() as u32, target) }; let buf = ImageBuffer::, Vec>::from_raw(w, h, rgb) .expect("buffer size matches dims"); let r = imageops::resize(&buf, nw, nh, FilterType::Triangle); (r.width(), r.height(), r.into_raw()) } else { (w, h, rgb) }; Thumb { rgb, width: w, height: h, src_width: src_w, src_height: src_h, orientation } }