Files
ImageApi/src/exif.rs
Cameron Cordes 00b3c80141 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>
2026-04-28 16:52:10 +00:00

429 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::fs::File;
use std::io::{BufReader, Read, Seek, SeekFrom};
use std::path::Path;
use anyhow::{Result, anyhow};
use exif::{In, Reader, Tag, Value};
use image::DynamicImage;
use log::debug;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExifData {
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub lens_model: Option<String>,
pub width: Option<i32>,
pub height: Option<i32>,
pub orientation: Option<i32>,
pub gps_latitude: Option<f64>,
pub gps_longitude: Option<f64>,
pub gps_altitude: Option<f64>,
pub focal_length: Option<f64>,
pub aperture: Option<f64>,
pub shutter_speed: Option<String>,
pub iso: Option<i32>,
pub date_taken: Option<i64>,
}
/// TIFF-based RAW formats where `JPEGInterchangeFormat` offsets are
/// absolute file offsets (the file itself is a TIFF container).
pub fn is_tiff_raw(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_lowercase())
.as_deref(),
Some(
"tiff" | "tif" | "nef" | "cr2" | "arw" | "dng" | "raf" | "orf" | "rw2" | "pef" | "srw"
)
)
}
/// 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, ifd)?
.value
.get_uint(0)?;
let length = exif
.get_field(Tag::JPEGInterchangeFormatLength, ifd)?
.value
.get_uint(0)?;
if length == 0 {
return None;
}
let mut file = File::open(path).ok()?;
file.seek(SeekFrom::Start(offset as u64)).ok()?;
let mut buf = vec![0u8; length as usize];
file.read_exact(&mut buf).ok()?;
if buf.len() < 2 || buf[0] != 0xFF || buf[1] != 0xD8 {
return None;
}
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();
matches!(
ext_lower.as_str(),
// JPEG formats
"jpg" | "jpeg" |
// TIFF and RAW formats based on TIFF
"tiff" | "tif" | "nef" | "cr2" | "cr3" | "arw" | "dng" | "raf" | "orf" | "rw2" | "pef" | "srw" |
// HEIF and variants
"heif" | "heic" | "avif" |
// PNG
"png" |
// WebP
"webp"
)
} else {
false
}
}
pub fn extract_exif_from_path(path: &Path) -> Result<ExifData> {
debug!("Extracting EXIF from: {:?}", path);
if !supports_exif(path) {
return Err(anyhow!("File type does not support EXIF"));
}
let file = File::open(path)?;
let mut bufreader = BufReader::new(file);
let exifreader = Reader::new();
let exif = exifreader.read_from_container(&mut bufreader)?;
let mut data = ExifData::default();
for field in exif.fields() {
match field.tag {
Tag::Make => {
data.camera_make = get_string_value(field);
}
Tag::Model => {
data.camera_model = get_string_value(field);
}
Tag::LensModel => {
data.lens_model = get_string_value(field);
}
Tag::PixelXDimension | Tag::ImageWidth => {
if data.width.is_none() {
data.width = get_u32_value(field).map(|v| v as i32);
}
}
Tag::PixelYDimension | Tag::ImageLength => {
if data.height.is_none() {
data.height = get_u32_value(field).map(|v| v as i32);
}
}
Tag::Orientation => {
data.orientation = get_u32_value(field).map(|v| v as i32);
}
Tag::FocalLength => {
data.focal_length = get_rational_value(field);
}
Tag::FNumber => {
data.aperture = get_rational_value(field);
}
Tag::ExposureTime => {
data.shutter_speed = get_rational_string(field);
}
Tag::PhotographicSensitivity | Tag::ISOSpeed => {
if data.iso.is_none() {
data.iso = get_u32_value(field).map(|v| v as i32);
}
}
Tag::DateTime | Tag::DateTimeOriginal => {
if data.date_taken.is_none() {
data.date_taken = parse_exif_datetime(field);
}
}
_ => {}
}
}
// Extract GPS coordinates
if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) {
data.gps_latitude = Some(lat);
}
if let Some(lon) = extract_gps_coordinate(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef) {
data.gps_longitude = Some(lon);
}
if let Some(alt) = extract_gps_altitude(&exif) {
data.gps_altitude = Some(alt);
}
debug!("Extracted EXIF data: {:?}", data);
Ok(data)
}
/// Read just the EXIF Orientation tag (1..=8) from a file. Cheaper than a
/// full `extract_exif_from_path` when the caller only needs orientation —
/// e.g. the thumbnail pipeline, which has to bake the rotation into the
/// resized pixels because the saved thumb has no EXIF chunk for the browser
/// to apply.
pub fn read_orientation(path: &Path) -> Option<i32> {
let file = File::open(path).ok()?;
let mut reader = BufReader::new(file);
let exif = Reader::new().read_from_container(&mut reader).ok()?;
let field = exif.get_field(Tag::Orientation, In::PRIMARY)?;
get_u32_value(field).map(|v| v as i32)
}
/// Apply an EXIF Orientation (1..=8) to a `DynamicImage`, returning a
/// canonically-oriented copy. Orientations:
/// 1 → as-is, 2 → flipH, 3 → rot180, 4 → flipV,
/// 5 → rot90CW + flipH, 6 → rot90CW, 7 → rot270CW + flipH, 8 → rot270CW.
/// Anything else (missing tag, garbage values) is returned unchanged.
pub fn apply_orientation(img: DynamicImage, orientation: i32) -> DynamicImage {
match orientation {
2 => img.fliph(),
3 => img.rotate180(),
4 => img.flipv(),
5 => img.rotate90().fliph(),
6 => img.rotate90(),
7 => img.rotate270().fliph(),
8 => img.rotate270(),
_ => img,
}
}
fn get_string_value(field: &exif::Field) -> Option<String> {
match &field.value {
Value::Ascii(vec) => {
if let Some(bytes) = vec.first() {
String::from_utf8(bytes.to_vec())
.ok()
.map(|s| s.trim_end_matches('\0').to_string())
} else {
None
}
}
_ => {
let display = field.display_value().to_string();
if display.is_empty() {
None
} else {
Some(display)
}
}
}
}
fn get_u32_value(field: &exif::Field) -> Option<u32> {
match &field.value {
Value::Short(vec) => vec.first().map(|&v| v as u32),
Value::Long(vec) => vec.first().copied(),
_ => None,
}
}
fn get_rational_value(field: &exif::Field) -> Option<f64> {
match &field.value {
Value::Rational(vec) => {
if let Some(rational) = vec.first() {
if rational.denom == 0 {
None
} else {
Some(rational.num as f64 / rational.denom as f64)
}
} else {
None
}
}
_ => None,
}
}
fn get_rational_string(field: &exif::Field) -> Option<String> {
match &field.value {
Value::Rational(vec) => {
if let Some(rational) = vec.first() {
if rational.denom == 0 {
None
} else if rational.num < rational.denom {
Some(format!("{}/{}", rational.num, rational.denom))
} else {
let value = rational.num as f64 / rational.denom as f64;
Some(format!("{:.2}", value))
}
} else {
None
}
}
_ => None,
}
}
fn parse_exif_datetime(field: &exif::Field) -> Option<i64> {
if let Some(datetime_str) = get_string_value(field) {
use chrono::NaiveDateTime;
// EXIF datetime format: "YYYY:MM:DD HH:MM:SS"
// Note: EXIF dates are local time without timezone info
// We return the timestamp as if it were UTC, and the client will display it as-is
NaiveDateTime::parse_from_str(&datetime_str, "%Y:%m:%d %H:%M:%S")
.ok()
.map(|dt| dt.and_utc().timestamp())
} else {
None
}
}
fn extract_gps_coordinate(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<f64> {
let coord_field = exif.get_field(coord_tag, In::PRIMARY)?;
let ref_field = exif.get_field(ref_tag, In::PRIMARY)?;
let coordinates = match &coord_field.value {
Value::Rational(vec) => {
if vec.len() < 3 {
return None;
}
let degrees = vec[0].num as f64 / vec[0].denom as f64;
let minutes = vec[1].num as f64 / vec[1].denom as f64;
let seconds = vec[2].num as f64 / vec[2].denom as f64;
degrees + (minutes / 60.0) + (seconds / 3600.0)
}
_ => return None,
};
let reference = get_string_value(ref_field)?;
let sign = if reference.starts_with('S') || reference.starts_with('W') {
-1.0
} else {
1.0
};
Some(coordinates * sign)
}
fn extract_gps_altitude(exif: &exif::Exif) -> Option<f64> {
let alt_field = exif.get_field(Tag::GPSAltitude, In::PRIMARY)?;
match &alt_field.value {
Value::Rational(vec) => {
if let Some(rational) = vec.first() {
if rational.denom == 0 {
None
} else {
let altitude = rational.num as f64 / rational.denom as f64;
// Check if below sea level
if let Some(ref_field) = exif.get_field(Tag::GPSAltitudeRef, In::PRIMARY)
&& let Some(ref_val) = get_u32_value(ref_field)
&& ref_val == 1
{
return Some(-altitude);
}
Some(altitude)
}
} else {
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_supports_exif_jpeg() {
assert!(supports_exif(Path::new("test.jpg")));
assert!(supports_exif(Path::new("test.jpeg")));
assert!(supports_exif(Path::new("test.JPG")));
}
#[test]
fn test_supports_exif_raw_formats() {
assert!(supports_exif(Path::new("test.nef"))); // Nikon
assert!(supports_exif(Path::new("test.NEF")));
assert!(supports_exif(Path::new("test.cr2"))); // Canon
assert!(supports_exif(Path::new("test.cr3"))); // Canon
assert!(supports_exif(Path::new("test.arw"))); // Sony
assert!(supports_exif(Path::new("test.dng"))); // Adobe DNG
}
#[test]
fn test_supports_exif_tiff() {
assert!(supports_exif(Path::new("test.tiff")));
assert!(supports_exif(Path::new("test.tif")));
assert!(supports_exif(Path::new("test.TIFF")));
}
#[test]
fn test_supports_exif_heif() {
assert!(supports_exif(Path::new("test.heif")));
assert!(supports_exif(Path::new("test.heic")));
assert!(supports_exif(Path::new("test.avif")));
}
#[test]
fn test_supports_exif_png_webp() {
assert!(supports_exif(Path::new("test.png")));
assert!(supports_exif(Path::new("test.PNG")));
assert!(supports_exif(Path::new("test.webp")));
assert!(supports_exif(Path::new("test.WEBP")));
}
#[test]
fn test_supports_exif_unsupported() {
assert!(!supports_exif(Path::new("test.mp4")));
assert!(!supports_exif(Path::new("test.mov")));
assert!(!supports_exif(Path::new("test.txt")));
assert!(!supports_exif(Path::new("test.gif")));
}
#[test]
fn test_supports_exif_no_extension() {
assert!(!supports_exif(Path::new("test")));
}
}