Show the photograph the way it was taken
Nothing read EXIF orientation, so every frame from a body held sideways lay on its side — in the grid, in develop, and in the read-only preview. The tag is honoured as part of *reading the file*, at the same standing as a RAW's masked-photosite crop, never as an edit. It lives as a baseline on Framing rather than as a starting value for quarter_turns, which is what keeps four things true: a sideways file opens unmodified, reset returns it to upright rather than to the sensor's scan order, its sidecar stays empty, and the rotate button still moves the image 90° whatever the file underneath it says. Framing::effective composes the baseline with the user's own turns through the group law rather than by adding turns and OR-ing flags. The naive version gets one case wrong — an odd baseline turn plus a user mirror — and gets it wrong quietly, because the result is still a plausible orientation. The composition collapses to a single permutation, so obeying the tag costs nothing per pixel. dr_decode::orientation is a header-only IFD walk, separate from metadata() for the reason the entry points are separate at all: the grid asks once per cell and must not build a rawler decoder to get one tag. CR3 and RAF fall back to the full read, being neither TIFF nor JPEG. Written down as FR-DEV-3h. Known gap: thumbnails cached before this stay sideways. The store is keyed by file and size, and its shards sync — invalidating them would have every client re-download 25 MB a shard, which is not this commit's call to make. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -470,6 +470,11 @@ const EXIF_IFD_POINTER: u16 = 0x8769;
|
||||
mod exif_tag {
|
||||
pub const MAKE: u16 = 0x010F;
|
||||
pub const MODEL: u16 = 0x0110;
|
||||
/// How the stored pixels sit relative to how the image should be seen.
|
||||
///
|
||||
/// Lives in the main IFD rather than the Exif sub-IFD, which is why it is
|
||||
/// found at all: the sub-IFD is where the *capture* tags are.
|
||||
pub const ORIENTATION: u16 = 0x0112;
|
||||
/// When the shutter fired. Absent on scanner output.
|
||||
pub const DATE_TIME_ORIGINAL: u16 = 0x9003;
|
||||
/// When the file was written. A camera sets both; a **scanner sets only
|
||||
@@ -511,6 +516,17 @@ fn read_exif_entries(
|
||||
exif_tag::ISO => md.iso = r.scalar(e),
|
||||
exif_tag::PIXEL_X => md.width = r.scalar(e),
|
||||
exif_tag::PIXEL_Y => md.height = r.scalar(e),
|
||||
// First IFD wins, unlike the fields above, which take the last
|
||||
// reading. This loop visits every IFD in the file, and a TIFF's
|
||||
// second one describes the *embedded thumbnail* — which some
|
||||
// bodies write already upright, tagged `1`. Letting that overwrite
|
||||
// the main image's tag would lay every portrait frame on its side.
|
||||
exif_tag::ORIENTATION => {
|
||||
if let Some(v) = r.scalar(e) {
|
||||
md.orientation
|
||||
.get_or_insert_with(|| dr_types::Orientation::from_exif(v as u16));
|
||||
}
|
||||
}
|
||||
exif_tag::DATE_TIME_ORIGINAL => {
|
||||
if let Some(t) = r.ascii(e).as_deref().and_then(crate::parse_exif_datetime) {
|
||||
md.captured_at = Some(t);
|
||||
@@ -575,6 +591,77 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// Build a little-endian TIFF with two chained IFDs.
|
||||
///
|
||||
/// The second one stands in for a TIFF's thumbnail IFD, which is where the
|
||||
/// orientation test's whole point lives.
|
||||
fn tiff_two_ifds(first: &[(u16, u16, u32, u32)], second: &[(u16, u16, u32, u32)]) -> Vec<u8> {
|
||||
// IFD0 occupies 2 + 12n + 4 bytes from offset 8.
|
||||
let second_at = 8 + 2 + 12 * first.len() as u32 + 4;
|
||||
let mut v = tiff(first, second_at);
|
||||
v.truncate(second_at as usize);
|
||||
|
||||
v.extend_from_slice(&(second.len() as u16).to_le_bytes());
|
||||
for (tag, kind, count, value) in second {
|
||||
v.extend_from_slice(&tag.to_le_bytes());
|
||||
v.extend_from_slice(&kind.to_le_bytes());
|
||||
v.extend_from_slice(&count.to_le_bytes());
|
||||
v.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
v.extend_from_slice(&0u32.to_le_bytes());
|
||||
v.resize(v.len().max(1024), 0);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_grid_reads_a_jpegs_orientation_without_decoding_it() {
|
||||
// The exact call the thumbnail worker makes, on the exact bytes it
|
||||
// has: a header, no pixels. Going through `metadata` instead would
|
||||
// build a rawler decoder per grid cell.
|
||||
let jpeg = jpeg_with_exif(&[(exif_tag::ORIENTATION, 3, 1, 6)], &[]);
|
||||
assert_eq!(
|
||||
crate::orientation(&jpeg),
|
||||
Some(dr_types::Orientation::from_exif(6))
|
||||
);
|
||||
|
||||
// And a file that says nothing declines rather than guessing.
|
||||
let plain = jpeg_with_exif(&[(exif_tag::ISO, 3, 1, 400)], &[]);
|
||||
assert_eq!(crate::orientation(&plain), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_is_read_from_the_main_ifd() {
|
||||
// 6 is "rotate 90° clockwise to display" — a phone or a body held on
|
||||
// its side, which is the case this whole path exists for.
|
||||
let h = tiff(&[(exif_tag::ORIENTATION, 3, 1, 6)], 0);
|
||||
let md = tiff_metadata(&h).expect("metadata");
|
||||
assert_eq!(md.orientation, Some(dr_types::Orientation::from_exif(6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_no_orientation_tag_reports_none_rather_than_upright() {
|
||||
// "Nothing was said" and "the camera was level" are different claims.
|
||||
// They are displayed alike, but only one of them can later be
|
||||
// distinguished from a deliberate `1`.
|
||||
let h = tiff(&[(tag::IMAGE_WIDTH, 4, 1, 1620)], 0);
|
||||
let md = tiff_metadata(&h).expect("metadata");
|
||||
assert_eq!(md.orientation, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thumbnail_ifd_does_not_overwrite_the_main_images_orientation() {
|
||||
// The regression this guards: some bodies write their embedded
|
||||
// thumbnail already upright and tag that IFD `1`. Reading every IFD
|
||||
// last-wins — which is right for make, model and the dates — would
|
||||
// take the thumbnail's `1` and lay every portrait frame on its side.
|
||||
let h = tiff_two_ifds(
|
||||
&[(exif_tag::ORIENTATION, 3, 1, 8)],
|
||||
&[(exif_tag::ORIENTATION, 3, 1, 1)],
|
||||
);
|
||||
let md = tiff_metadata(&h).expect("metadata");
|
||||
assert_eq!(md.orientation, Some(dr_types::Orientation::from_exif(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_a_jpeg_interchange_preview() {
|
||||
let h = tiff(
|
||||
|
||||
Reference in New Issue
Block a user