feat/raw-thumb-embedded-preview #59
61
src/exif.rs
61
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<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 (~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<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();
|
||||
|
||||
17
src/main.rs
17
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 `<img src=...>` 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)
|
||||
|
||||
Reference in New Issue
Block a user