fix(images): pick the decoder from the bytes, not the extension
`image::open` chooses the decoder by extension, so a PNG saved as .jpg (Android screenshots, chat exports) failed thumbnails, previews, perceptual hashes, insights and face crops with "Illegal start bytes: 8950". New `file_types::open_image` sniffs the header and falls back to the extension when it's unknown; every decode site uses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bc27aea81a
commit
b95adb4f77
5 files changed
+29
-6
No files matched your search
@@ -463,7 +463,7 @@ impl InsightGenerator {
|
|||||||
log::debug!("Loading image for vision model: {:?}", full_path);
|
log::debug!("Loading image for vision model: {:?}", full_path);
|
||||||
|
|
||||||
// Open and decode the image
|
// 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))?;
|
.map_err(|e| anyhow::anyhow!("Failed to open image file: {}", e))?;
|
||||||
|
|
||||||
let (original_width, original_height) = (img.width(), img.height());
|
let (original_width, original_height) = (img.width(), img.height());
|
||||||
|
|||||||
+3
-2
@@ -2179,7 +2179,7 @@ async fn person_faces_handler<D: FaceDao>(
|
|||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Crop `abs_path` to the normalized bbox and re-encode as JPEG for the
|
/// 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
|
/// 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).
|
/// face on a thumbnail of a non-decodable file anyway).
|
||||||
fn crop_image_to_bbox(
|
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 {
|
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"));
|
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 /
|
// EXIF rotation: the bbox arrives in display space (the carousel /
|
||||||
// overlay are rendered post-rotation by the browser), but the
|
// overlay are rendered post-rotation by the browser), but the
|
||||||
// `image` crate hands us raw pre-rotation pixels. For any phone
|
// `image` crate hands us raw pre-rotation pixels. For any phone
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ pub fn direntry_is_media(entry: &DirEntry) -> bool {
|
|||||||
is_media_file(entry.path())
|
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::DynamicImage> {
|
||||||
|
image::ImageReader::open(path)?
|
||||||
|
.with_guessed_format()?
|
||||||
|
.decode()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -190,4 +200,16 @@ mod tests {
|
|||||||
assert!(is_image_file(Path::new(".cover.jpg")));
|
assert!(is_image_file(Path::new(".cover.jpg")));
|
||||||
assert!(!is_filesystem_metadata(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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ pub struct PerceptualIdentity {
|
|||||||
/// callers should treat that as "no perceptual signal available" and
|
/// callers should treat that as "no perceptual signal available" and
|
||||||
/// proceed with exact-match dedup only.
|
/// proceed with exact-match dedup only.
|
||||||
pub fn compute(path: &Path) -> Option<PerceptualIdentity> {
|
pub fn compute(path: &Path) -> Option<PerceptualIdentity> {
|
||||||
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
|
// 8x8 = 64 bits, the standard size for pHash/dHash. Larger sizes
|
||||||
// give more discriminative power but no longer fit in i64 and the
|
// give more discriminative power but no longer fit in i64 and the
|
||||||
|
|||||||
+2
-2
@@ -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);
|
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))
|
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e))
|
||||||
})?;
|
})?;
|
||||||
let img = exif::apply_orientation(img, orientation);
|
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);
|
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))
|
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("{:?}: {}", src, e))
|
||||||
})?;
|
})?;
|
||||||
let img = exif::apply_orientation(img, orientation);
|
let img = exif::apply_orientation(img, orientation);
|
||||||
|
|||||||
Reference in new issue
Block a user