feat/raw-thumb-embedded-preview #59

Merged
cameron merged 2 commits from feat/raw-thumb-embedded-preview into master 2026-04-28 17:21:29 +00:00
2 changed files with 59 additions and 19 deletions
Showing only changes of commit 00b3c80141 - Show all commits

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();

View File

@@ -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 `<img src=...>` lands as a broken image. Serve
// the embedded JPEG preview instead (typically the camera's in-body
// review JPEG, ~12 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)