From 00b3c80141fe51b7948e75b821ea21d7323e899d Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Tue, 28 Apr 2026 16:52:10 +0000 Subject: [PATCH] RAW: try IFD0 + IFD1 for embedded preview, serve at full size The thumbnail pipeline's embedded-JPEG extractor only checked IFD1 (THUMBNAIL), which on many Nikon NEFs is missing or zero-length even when IFD0 (PRIMARY) carries a perfectly good 1-2 MP reduced-resolution preview the camera writes for in-body review. The previous behavior produced black thumbs on disk: the buggy IFD1 pointer resolved to a short byte sequence that happened to satisfy the SOI sanity check, image::load_from_memory accepted it, and the resize path quietly wrote a black JPEG. Now both IFDs are checked and the larger valid JPEG wins. Format- agnostic: applies to every TIFF-based RAW (NEF / ARW / CR2 / DNG / RAF / ORF / RW2 / PEF / SRW / TIFF). is_tiff_raw is now pub so main.rs can gate its full-size handler on it. Also extends the /image handler so size=full requests for RAW formats serve the embedded preview as image/jpeg instead of NamedFile-streaming the original RAW bytes - browsers can't decode a .nef container, so would otherwise land as a broken image. Falls through to NamedFile if no preview is present, preserving the historical behavior for callers that genuinely want the original bytes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/exif.rs | 61 ++++++++++++++++++++++++++++++++++++----------------- src/main.rs | 17 +++++++++++++++ 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/src/exif.rs b/src/exif.rs index eca6eb7..6b14cca 100644 --- a/src/exif.rs +++ b/src/exif.rs @@ -28,7 +28,7 @@ pub struct ExifData { /// TIFF-based RAW formats where `JPEGInterchangeFormat` offsets are /// absolute file offsets (the file itself is a TIFF container). -fn is_tiff_raw(path: &Path) -> bool { +pub fn is_tiff_raw(path: &Path) -> bool { matches!( path.extension() .and_then(|e| e.to_str()) @@ -40,26 +40,18 @@ fn is_tiff_raw(path: &Path) -> bool { ) } -/// Returns the bytes of the embedded JPEG thumbnail in a TIFF-based RAW or -/// TIFF file. Used to thumbnail formats whose RAW pixel data can't be decoded -/// by our normal tools (e.g. Sony ARW). Returns `None` if no preview is -/// present, the file isn't a TIFF container, or the data doesn't look like -/// a valid JPEG. -pub fn extract_embedded_jpeg_preview(path: &Path) -> Option> { - if !is_tiff_raw(path) { - return None; - } - - let file = File::open(path).ok()?; - let mut bufreader = BufReader::new(file); - let exif = Reader::new().read_from_container(&mut bufreader).ok()?; - +/// Read the JPEG bytes pointed to by `JPEGInterchangeFormat` / +/// `JPEGInterchangeFormatLength` in a single IFD. Returns `None` on any +/// failure: tags missing, length zero, file read failure, or bytes that +/// don't start with the JPEG SOI marker (some MakerNote pointers reference +/// TIFF-wrapped previews or other non-JPEG payloads we can't load). +fn read_jpeg_at_ifd(exif: &exif::Exif, path: &Path, ifd: In) -> Option> { let offset = exif - .get_field(Tag::JPEGInterchangeFormat, In::THUMBNAIL)? + .get_field(Tag::JPEGInterchangeFormat, ifd)? .value .get_uint(0)?; let length = exif - .get_field(Tag::JPEGInterchangeFormatLength, In::THUMBNAIL)? + .get_field(Tag::JPEGInterchangeFormatLength, ifd)? .value .get_uint(0)?; if length == 0 { @@ -71,8 +63,6 @@ pub fn extract_embedded_jpeg_preview(path: &Path) -> Option> { let mut buf = vec![0u8; length as usize]; file.read_exact(&mut buf).ok()?; - // JPEG SOI marker sanity check — MakerNote offsets sometimes point at - // TIFF-wrapped previews or other non-JPEG data. if buf.len() < 2 || buf[0] != 0xFF || buf[1] != 0xD8 { return None; } @@ -80,6 +70,39 @@ pub fn extract_embedded_jpeg_preview(path: &Path) -> Option> { Some(buf) } +/// Returns the bytes of the embedded JPEG preview in a TIFF-based RAW or +/// TIFF file. Used to thumbnail formats whose RAW pixel data can't be decoded +/// by our normal tools (e.g. Sony ARW), and to serve a usable full-size +/// image for clients that can't decode the RAW container directly. Returns +/// `None` if no preview is present, the file isn't a TIFF container, or the +/// data doesn't look like a valid JPEG. +/// +/// Both IFD0 (PRIMARY) and IFD1 (THUMBNAIL) are checked, preferring the +/// larger valid JPEG. Conventions vary by camera: most modern Nikon NEFs +/// expose the larger reduced-resolution preview (~1–2 MP) via IFD0 and a +/// small chip via IFD1; some bodies leave one or the other empty or zero- +/// length, and an earlier THUMBNAIL-only implementation produced black +/// thumbnails for any NEF whose IFD1 thumbnail was missing or corrupted. +pub fn extract_embedded_jpeg_preview(path: &Path) -> Option> { + if !is_tiff_raw(path) { + return None; + } + + let file = File::open(path).ok()?; + let mut bufreader = BufReader::new(file); + let exif = Reader::new().read_from_container(&mut bufreader).ok()?; + + let primary = read_jpeg_at_ifd(&exif, path, In::PRIMARY); + let thumbnail = read_jpeg_at_ifd(&exif, path, In::THUMBNAIL); + + match (primary, thumbnail) { + (Some(p), Some(t)) => Some(if p.len() >= t.len() { p } else { t }), + (Some(p), None) => Some(p), + (None, Some(t)) => Some(t), + (None, None) => None, + } +} + pub fn supports_exif(path: &Path) -> bool { if let Some(ext) = path.extension() { let ext_lower = ext.to_string_lossy().to_lowercase(); diff --git a/src/main.rs b/src/main.rs index 5e87129..12a4003 100644 --- a/src/main.rs +++ b/src/main.rs @@ -215,6 +215,23 @@ async fn get_image( } } + // Full-size requests for RAW formats (NEF/CR2/ARW/etc.) can't just + // NamedFile-stream the original bytes — browsers won't decode the + // RAW container, so a `` lands as a broken image. Serve + // the embedded JPEG preview instead (typically the camera's in-body + // review JPEG, ~1–2 MP). Falls through to NamedFile if no preview is + // available, which preserves the historical behavior for callers + // that genuinely want the original bytes. + if image_size == PhotoSize::Full && exif::is_tiff_raw(&path) { + if let Some(preview) = exif::extract_embedded_jpeg_preview(&path) { + span.set_status(Status::Ok); + return HttpResponse::Ok() + .content_type("image/jpeg") + .insert_header(("Cache-Control", "public, max-age=3600")) + .body(preview); + } + } + if let Ok(file) = NamedFile::open(&path) { span.set_status(Status::Ok); // Enable ETag and set cache headers for full images (1 hour cache)