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
+108
View File
@@ -26,6 +26,44 @@ pub struct Preview {
}
impl Preview {
/// TRACES: FR-DEV-3h
/// Turn the pixels the right way up, in place.
///
/// Every path that shows a preview without the GPU needs this: the grid's
/// thumbnails, and the read-only fallback develop shows when no decoder
/// could open the file. An embedded preview is written in the sensor's
/// orientation, not the photograph's, so a phone or a camera held sideways
/// fills the grid with frames on their side until this runs.
///
/// Done before [`Self::downscale_to`] would be wasteful and after it is
/// not: a quarter turn is a permutation, so it costs the same either way,
/// and doing it on the smaller buffer moves a fraction of the bytes.
///
/// Allocates a second buffer rather than rotating in place. An in-place
/// quarter turn on a non-square image is a cycle-following permutation
/// that is both slower per pixel and far harder to get right, for a saving
/// that a thumbnail-sized buffer does not need.
pub fn apply_orientation(&mut self, orientation: dr_types::Orientation) {
if orientation.is_normal() || self.width == 0 || self.height == 0 {
return;
}
let (dw, dh) = orientation.oriented_size(self.width, self.height);
let mut out = vec![0u8; (dw as usize) * (dh as usize) * 4];
for y in 0..dh {
for x in 0..dw {
let (sx, sy) = orientation.source_pixel(x, y, dw, dh);
let s = ((sy * self.width + sx) * 4) as usize;
let d = ((y * dw + x) * 4) as usize;
out[d..d + 4].copy_from_slice(&self.rgba[s..s + 4]);
}
}
self.rgba = out;
self.width = dw;
self.height = dh;
}
/// Downscale in place to fit within `max_dim` on the long edge.
///
/// A 5472x3648 preview is 79.8 MB of RGBA — far more than a grid cell or
@@ -237,6 +275,76 @@ fn rgb_to_rgba(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
mod tests {
use super::*;
/// A preview whose every pixel encodes its own coordinates, so a
/// misplaced one is identifiable rather than merely wrong.
fn coded(width: u32, height: u32) -> Preview {
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
for y in 0..height {
for x in 0..width {
rgba.extend_from_slice(&[x as u8, y as u8, 0, 255]);
}
}
Preview {
width,
height,
rgba,
}
}
#[test]
fn a_quarter_turn_moves_every_pixel_where_the_orientation_says() {
// Tag 6: the stored image's first row becomes the displayed right
// edge, its first column the displayed top. A 4x2 landscape preview
// therefore comes out 2x4 portrait, with stored (0,0) at the top right.
let mut p = coded(4, 2);
p.apply_orientation(dr_types::Orientation::from_exif(6));
assert_eq!((p.width, p.height), (2, 4));
let at = |x: u32, y: u32| {
let i = ((y * p.width + x) * 4) as usize;
(p.rgba[i], p.rgba[i + 1])
};
// Displayed top-right reads stored (0, 0).
assert_eq!(at(1, 0), (0, 0));
// Displayed top-left reads stored (0, 1) — the last row of column 0.
assert_eq!(at(0, 0), (0, 1));
// Displayed bottom-right reads stored (3, 0).
assert_eq!(at(1, 3), (3, 0));
}
#[test]
fn an_upright_file_is_left_untouched() {
// The common case, and the one where an unnecessary reallocation
// would be paid on every thumbnail in the library.
let original = coded(4, 2);
let mut p = original.clone();
p.apply_orientation(dr_types::Orientation::NORMAL);
assert_eq!(p, original);
}
#[test]
fn every_orientation_preserves_the_pixels_it_was_given() {
// A turn or a mirror is a permutation: the same bytes, rearranged.
// Anything else means a pixel was dropped, duplicated or read out of
// bounds — and the bounds case would have panicked first.
for tag in 1..=8u16 {
let orientation = dr_types::Orientation::from_exif(tag);
let mut p = coded(5, 3);
p.apply_orientation(orientation);
assert_eq!(
(p.width, p.height),
orientation.oriented_size(5, 3),
"tag {tag}"
);
let mut got: Vec<_> = p.rgba.chunks(4).map(|c| (c[0], c[1])).collect();
got.sort_unstable();
let mut want: Vec<_> = coded(5, 3).rgba.chunks(4).map(|c| (c[0], c[1])).collect();
want.sort_unstable();
assert_eq!(got, want, "tag {tag}");
}
}
#[test]
fn size_preference_falls_through_in_order() {
// A container missing the requested size must yield the next