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:
2026-08-16 15:37:05 +02:00
co-authored by Claude Opus 5
parent b044a8c067
commit 489465faf0
12 changed files with 875 additions and 48 deletions
+53 -3
View File
@@ -23,7 +23,7 @@ pub use preview::{
PREVIEW_PROBE_BYTES,
};
use dr_types::Format;
use dr_types::{Format, Orientation};
/// Capture metadata read from a file header.
#[derive(Debug, Clone, Default, PartialEq)]
@@ -39,6 +39,15 @@ pub struct Metadata {
/// Full sensor dimensions, before crop.
pub width: Option<u32>,
pub height: Option<u32>,
/// How the stored pixels sit relative to how the photograph should be
/// seen (EXIF `0x0112`).
///
/// `None` where the file carries no tag, which is not the same claim as
/// [`Orientation::NORMAL`]: the first says nothing is known, the second
/// says the camera was held level. Callers treat them alike — an unknown
/// orientation is displayed as-is — but keeping them apart means a future
/// "rotate on import" pass can tell a deliberate `1` from a silent gap.
pub orientation: Option<Orientation>,
/// When the shutter fired, as Unix seconds.
///
/// EXIF records wall-clock time with no zone, so this is that reading
@@ -259,6 +268,7 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
focal_length: exif.focal_length.map(|r| r.n as f32 / r.d.max(1) as f32),
width: None,
height: None,
orientation: exif.orientation.map(Orientation::from_exif),
captured_at: exif
.date_time_original
.as_deref()
@@ -275,18 +285,58 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
// comes back empty. These formats *are* TIFF, so the same reader the JPEG
// path uses can find it. Only the missing fields are filled, so rawler
// stays authoritative wherever it did answer.
if out.captured_at.is_none() {
//
// Orientation joins the trigger for the same reason it joins the fills: a
// sideways frame that rawler declined to report is displayed on its side,
// which is a louder failure than a missing date and just as recoverable
// from the IFD the tag sits in. The extra walk is over bytes already in
// memory, and only for files that came back short.
if out.captured_at.is_none() || out.orientation.is_none() {
if let Ok(fallback) = locate::tiff_metadata(bytes) {
out.captured_at = fallback.captured_at;
out.captured_at = out.captured_at.or(fallback.captured_at);
out.captured_offset = out.captured_offset.or(fallback.captured_offset);
out.iso = out.iso.or(fallback.iso);
out.lens = out.lens.take().or(fallback.lens);
out.orientation = out.orientation.or(fallback.orientation);
}
}
Ok(out)
}
/// TRACES: FR-CAT-5 | FR-DEV-3h
/// Read just the stored orientation, from a file header.
///
/// Separate from [`metadata`] for the reason the four entry points are
/// separate at all (ARCH §3.2): the grid needs this for every cell it draws a
/// thumbnail into, and it already holds the header bytes. Going through
/// `metadata` would put a full rawler decoder construction behind one tag —
/// the same mistake as decoding sensor data to cull.
///
/// This is an IFD walk over bytes already in memory, so it costs effectively
/// nothing on the TIFF-derived formats and on JPEG. The two containers that
/// are neither — Canon's CR3, which is ISO-BMFF, and Fujifilm's RAF — fall
/// back to the full read, because the alternative is showing those bodies'
/// portrait frames on their side.
///
/// `None` means the header carried no orientation, which callers should treat
/// as [`Orientation::NORMAL`] rather than as a failure: most files have no tag.
pub fn orientation(header: &[u8]) -> Option<Orientation> {
let direct = if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
locate::jpeg_metadata(header).ok()
} else {
locate::tiff_metadata(header).ok()
};
if let Some(o) = direct.and_then(|m| m.orientation) {
return Some(o);
}
match probe(header) {
Some(Format::Cr3) | Some(Format::Raf) => metadata(header).ok().and_then(|m| m.orientation),
_ => None,
}
}
/// Parse an EXIF `DateTimeOriginal` into Unix seconds.
///
/// The format is `"YYYY:MM:DD HH:MM:SS"` — colons in the date, which is what