From 4720d5653aa54f1ba66e72de34a39d3ba9a0b72c Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Mon, 10 Aug 2026 21:15:01 -0400 Subject: [PATCH] feat: apply EXIF orientation and colorspace correction to ffmpeg thumbnails HEIC/HEIF sources use Display P3 color primaries. Without colorspace=bt709 the mjpeg encoder treated P3 values as sRGB, producing warm/oversaturated output. Also bake EXIF Orientation tag into pixels so saved JPEGs are canonically oriented. - Extract orientation from exif-reader and pass through all ffmpeg thumbnail paths (small, large, xlarge previews) - Add shared build_image_thumb_filter() for the 200px path - Add rotation + colorspace=bt709 to large/xlarge ffmpeg paths --- src/thumbnails.rs | 59 +++++++++++++++++++++++++++++++++++++-------- src/video/actors.rs | 39 ++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/src/thumbnails.rs b/src/thumbnails.rs index a7334b0..75a456f 100644 --- a/src/thumbnails.rs +++ b/src/thumbnails.rs @@ -98,7 +98,7 @@ pub fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Resul } if file_types::needs_ffmpeg_thumbnail(src) { - return generate_image_thumbnail_ffmpeg(src, thumb_path); + return generate_image_thumbnail_ffmpeg(src, thumb_path, orientation); } let img = image::open(src).map_err(|e| { @@ -140,7 +140,7 @@ pub fn generate_large_preview(src: &Path, dest: &Path) -> std::io::Result<()> { } if file_types::needs_ffmpeg_thumbnail(src) { - return generate_large_preview_ffmpeg(src, dest); + return generate_large_preview_ffmpeg(src, dest, orientation); } let img = image::open(src).map_err(|e| { @@ -175,14 +175,34 @@ fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<( /// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors /// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales /// to the large-preview cap instead of 200. -fn generate_large_preview_ffmpeg(src: &Path, dest: &Path) -> std::io::Result<()> { - // scale=W:-1 with force_original_aspect_ratio=decrease + the min(iw,W) - // trick caps the long edge regardless of orientation, mirroring what - // image::thumbnail does for the non-ffmpeg branch. - let vf = format!( +fn generate_large_preview_ffmpeg( + src: &Path, + dest: &Path, + orientation: i32, +) -> std::io::Result<()> { + // Rotation + scale + colorspace. HEIC sources use Display P3; without + // colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing + // warm/oversaturated output. The min(iw,cap) trick caps the long edge + // regardless of orientation, mirroring image::thumbnail. + let rotation = match orientation { + 2 => "hflip", + 3 => "transpose=2", + 4 => "vflip", + 5 => "transpose=0,hflip", + 6 => "transpose=0", + 7 => "transpose=1,hflip", + 8 => "transpose=1", + _ => "", + }; + let scale_expr = format!( "scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'", cap = LARGE_PREVIEW_MAX_DIM ); + let vf = if rotation.is_empty() { + format!("{},colorspace=bt709", scale_expr) + } else { + format!("{},{},colorspace=bt709", rotation, scale_expr) + }; let output = Command::new("ffmpeg") .arg("-y") .arg("-i") @@ -232,7 +252,7 @@ pub fn generate_xlarge_preview(src: &Path, dest: &Path) -> std::io::Result<()> { } if file_types::needs_ffmpeg_thumbnail(src) { - return generate_xlarge_preview_ffmpeg(src, dest); + return generate_xlarge_preview_ffmpeg(src, dest, orientation); } let img = image::open(src).map_err(|e| { @@ -260,11 +280,30 @@ fn encode_xlarge_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result< Ok(()) } -fn generate_xlarge_preview_ffmpeg(src: &Path, dest: &Path) -> std::io::Result<()> { - let vf = format!( +fn generate_xlarge_preview_ffmpeg( + src: &Path, + dest: &Path, + orientation: i32, +) -> std::io::Result<()> { + let rotation = match orientation { + 2 => "hflip", + 3 => "transpose=2", + 4 => "vflip", + 5 => "transpose=0,hflip", + 6 => "transpose=0", + 7 => "transpose=1,hflip", + 8 => "transpose=1", + _ => "", + }; + let scale_expr = format!( "scale='if(gt(iw,ih),min(iw,{cap}),-1)':'if(gt(iw,ih),-1,min(ih,{cap}))'", cap = XLARGE_PREVIEW_MAX_DIM ); + let vf = if rotation.is_empty() { + format!("{},colorspace=bt709", scale_expr) + } else { + format!("{},{},colorspace=bt709", rotation, scale_expr) + }; let output = Command::new("ffmpeg") .arg("-y") .arg("-i") diff --git a/src/video/actors.rs b/src/video/actors.rs index 22ec1ac..ba44329 100644 --- a/src/video/actors.rs +++ b/src/video/actors.rs @@ -90,10 +90,39 @@ pub fn generate_video_thumbnail(path: &Path, destination: &Path) -> std::io::Res Ok(()) } -/// Use ffmpeg to extract a 200px-wide thumbnail from formats the `image` crate -/// can't decode (RAW: NEF/ARW, HEIC/HEIF). Writes JPEG bytes to `destination` -/// regardless of its extension. -pub fn generate_image_thumbnail_ffmpeg(path: &Path, destination: &Path) -> std::io::Result<()> { +/// Build the ffmpeg filter chain for image thumbnails: rotation (from EXIF +/// orientation) + scale + color-space conversion. HEIC sources use Display P3 +/// primaries; without `colorspace=bt709` the mjpeg encoder treats P3 values +/// as sRGB, producing warm/oversaturated output. +fn build_image_thumb_filter(orientation: i32, scale_w: u32) -> String { + let rotation = match orientation { + 2 => "hflip", + 3 => "transpose=2", + 4 => "vflip", + 5 => "transpose=0,hflip", + 6 => "transpose=0", + 7 => "transpose=1,hflip", + 8 => "transpose=1", + _ => "", // orientation 1 or unknown — no rotation needed + }; + if rotation.is_empty() { + format!("scale={}:{{-1}},colorspace=bt709", scale_w) + } else { + format!("{},scale={}:{{-1}},colorspace=bt709", rotation, scale_w) + } +} + +/// Use ffmpeg to extract a thumbnail from formats the `image` crate can't +/// decode (HEIC/HEIF, RAW: NEF/ARW). `orientation` is the EXIF Orientation +/// tag value (1..=8) — baked into the pixels so the saved JPEG is +/// canonically oriented. Writes JPEG bytes to `destination` regardless of +/// its extension. +pub fn generate_image_thumbnail_ffmpeg( + path: &Path, + destination: &Path, + orientation: i32, +) -> std::io::Result<()> { + let vf = build_image_thumb_filter(orientation, 200); let output = Command::new("ffmpeg") .arg("-y") .arg("-i") @@ -101,7 +130,7 @@ pub fn generate_image_thumbnail_ffmpeg(path: &Path, destination: &Path) -> std:: .arg("-vframes") .arg("1") .arg("-vf") - .arg("scale=200:-1") + .arg(&vf) .arg("-f") .arg("image2") .arg("-c:v")