diff --git a/src/ai/insight_generator.rs b/src/ai/insight_generator.rs index df18fd4..d43f5f1 100644 --- a/src/ai/insight_generator.rs +++ b/src/ai/insight_generator.rs @@ -463,7 +463,7 @@ impl InsightGenerator { log::debug!("Loading image for vision model: {:?}", full_path); // Open and decode the image - let img = image::open(&full_path) + let img = crate::file_types::open_image(&full_path) .map_err(|e| anyhow::anyhow!("Failed to open image file: {}", e))?; let (original_width, original_height) = (img.width(), img.height()); diff --git a/src/faces.rs b/src/faces.rs index 645bb1c..edbb201 100644 --- a/src/faces.rs +++ b/src/faces.rs @@ -2179,7 +2179,7 @@ async fn person_faces_handler( // ── Helpers ───────────────────────────────────────────────────────────────── /// Crop `abs_path` to the normalized bbox and re-encode as JPEG for the -/// face service. `image::open` decodes most photo formats Apollo will see; +/// face service. `open_image` decodes most photo formats Apollo will see; /// HEIC/RAW are out of scope for the manual flow (the user can't draw a /// face on a thumbnail of a non-decodable file anyway). fn crop_image_to_bbox( @@ -2195,7 +2195,8 @@ fn crop_image_to_bbox( if nw <= 0.0 || nh <= 0.0 || nx + nw > 1.001 || ny + nh > 1.001 { return Err(anyhow!("bbox wh out of bounds or zero")); } - let raw = image::open(abs_path).with_context(|| format!("open {:?}", abs_path))?; + let raw = + crate::file_types::open_image(abs_path).with_context(|| format!("open {:?}", abs_path))?; // EXIF rotation: the bbox arrives in display space (the carousel / // overlay are rendered post-rotation by the browser), but the // `image` crate hands us raw pre-rotation pixels. For any phone diff --git a/src/file_types.rs b/src/file_types.rs index b834cba..ac7d856 100644 --- a/src/file_types.rs +++ b/src/file_types.rs @@ -115,6 +115,16 @@ pub fn direntry_is_media(entry: &DirEntry) -> bool { is_media_file(entry.path()) } +/// Decode an image by its bytes, not its extension. `image::open` picks +/// the decoder from the extension, so a PNG saved as `.jpg` (Android +/// screenshots, chat exports) fails with "Illegal start bytes: 8950". +/// Sniffing falls back to the extension when the header is unknown. +pub fn open_image(path: &Path) -> image::ImageResult { + image::ImageReader::open(path)? + .with_guessed_format()? + .decode() +} + #[cfg(test)] mod tests { use super::*; @@ -190,4 +200,16 @@ mod tests { assert!(is_image_file(Path::new(".cover.jpg"))); assert!(!is_filesystem_metadata(Path::new(".cover.jpg"))); } + + #[test] + fn open_image_sniffs_format_over_extension() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("Screenshot_2014-11-17-21-30-31.jpg"); + image::RgbImage::new(3, 2) + .save_with_format(&path, image::ImageFormat::Png) + .unwrap(); + assert!(image::open(&path).is_err(), "extension-based decode fails"); + let img = open_image(&path).unwrap(); + assert_eq!((img.width(), img.height()), (3, 2)); + } } diff --git a/src/perceptual_hash.rs b/src/perceptual_hash.rs index 619bfa5..0799efe 100644 --- a/src/perceptual_hash.rs +++ b/src/perceptual_hash.rs @@ -30,7 +30,7 @@ pub struct PerceptualIdentity { /// callers should treat that as "no perceptual signal available" and /// proceed with exact-match dedup only. pub fn compute(path: &Path) -> Option { - let img = image::open(path).ok()?; + let img = crate::file_types::open_image(path).ok()?; // 8x8 = 64 bits, the standard size for pHash/dHash. Larger sizes // give more discriminative power but no longer fit in i64 and the diff --git a/src/thumbnails.rs b/src/thumbnails.rs index 507c2b1..dcec895 100644 --- a/src/thumbnails.rs +++ b/src/thumbnails.rs @@ -104,7 +104,7 @@ pub fn generate_image_thumbnail(src: &Path, thumb_path: &Path) -> std::io::Resul return generate_image_thumbnail_ffmpeg(src, thumb_path, orientation); } - let img = image::open(src).map_err(|e| { + let img = file_types::open_image(src).map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e)) })?; let img = exif::apply_orientation(img, orientation); @@ -148,7 +148,7 @@ pub fn generate_preview(src: &Path, dest: &Path, max_dim: u32) -> std::io::Resul return generate_preview_ffmpeg(src, dest, orientation, max_dim); } - let img = image::open(src).map_err(|e| { + let img = file_types::open_image(src).map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e)) })?; let img = exif::apply_orientation(img, orientation);