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
+42
View File
@@ -683,6 +683,48 @@ mod tests {
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
}
#[test]
fn a_sidecar_neither_records_nor_erases_the_files_orientation() {
// How a file stored its pixels is a fact about the file, so it must
// not travel in the sidecar — a shared edit would then carry one
// camera's sensor scan onto another's. Two failures are checked
// together because they are the same mistake seen from each end.
let mut sideways = EditGraph::default_chain();
sideways.set_orientation(dr_types::Orientation::from_exif(6));
// Nothing was edited, so there is nothing to write. If the baseline
// leaked into `param`, a rotation would appear here.
let v = version_of(&sideways);
let text = {
let mut s = Sidecar::new();
s.put(v);
s.to_text()
};
assert!(
!text.contains("framing.rotation"),
"an untouched sideways file wrote a rotation:\n{text}"
);
// And applying an edit — which resets the graph first — must leave the
// orientation where it was, or reopening an edited portrait frame
// shows it on its side.
let mut edited = EditGraph::default_chain();
edited.set_param(framing::ID, framing::ANGLE, -1.5);
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&edited));
Sidecar::parse(&sidecar.to_text())
.expect("valid")
.default_version()
.expect("a version")
.apply(&mut sideways);
assert_eq!(
sideways.framing().baseline(),
dr_types::Orientation::from_exif(6)
);
assert_eq!(sideways.output_size(6000, 4000), (4000, 6000));
}
#[test]
fn applying_a_version_replaces_rather_than_overlays() {
// Loading an edit onto a graph that already holds one must not leave