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
<img src=...> 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) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-04-28 16:52:10 +00:00
parent a53c3ae514
commit 00b3c80141
2 changed files with 59 additions and 19 deletions

View File

@@ -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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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 (~12 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<Vec<u8>> {
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();