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:
+254
-14
@@ -185,6 +185,25 @@ pub struct Framing {
|
||||
quarter_turns: u8,
|
||||
flip_h: bool,
|
||||
flip_v: bool,
|
||||
/// TRACES: FR-DEV-3h
|
||||
/// How the file's pixels were stored, from its EXIF orientation.
|
||||
///
|
||||
/// **Not an edit**, and this is the whole reason it is a separate field
|
||||
/// rather than a starting value for `quarter_turns`. A camera held
|
||||
/// sideways stored its rows the way it always does and wrote a tag saying
|
||||
/// so; obeying that tag is part of reading the file, not a decision the
|
||||
/// user made. Folding it into `quarter_turns` would make every portrait
|
||||
/// frame open already-modified, write a rotation into every sidecar, and —
|
||||
/// worst — make "reset framing" lay the photograph on its side, since
|
||||
/// reset's whole meaning is "back to the file as it is".
|
||||
///
|
||||
/// So it sits underneath: [`Self::param`] and the sidecar see only the
|
||||
/// user's turns, while everything that renders or measures the frame sees
|
||||
/// the two composed. It composes exactly, because a quarter turn and two
|
||||
/// mirrors form a group of eight that is closed under composition — the
|
||||
/// pair always collapses back to one turn and two flags, so the shader
|
||||
/// still emits a single permutation and costs nothing for the baseline.
|
||||
baseline: dr_types::Orientation,
|
||||
crop: CropRect,
|
||||
/// Which part of the framed image the viewport is looking at.
|
||||
///
|
||||
@@ -207,6 +226,7 @@ impl Default for Framing {
|
||||
quarter_turns: 0,
|
||||
flip_h: false,
|
||||
flip_v: false,
|
||||
baseline: dr_types::Orientation::NORMAL,
|
||||
crop: CropRect::default(),
|
||||
view: CropRect::default(),
|
||||
}
|
||||
@@ -273,16 +293,60 @@ impl Framing {
|
||||
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
|
||||
}
|
||||
|
||||
/// How the file stored its pixels — see the field.
|
||||
pub fn baseline(&self) -> dr_types::Orientation {
|
||||
self.baseline
|
||||
}
|
||||
|
||||
/// Record the file's EXIF orientation.
|
||||
///
|
||||
/// Set once when the image is opened, before any edit is restored. It is
|
||||
/// deliberately not a `set_param`: the descriptor lists what the user can
|
||||
/// change, and this is a property of the file.
|
||||
pub fn set_baseline(&mut self, orientation: dr_types::Orientation) {
|
||||
self.baseline = orientation;
|
||||
}
|
||||
|
||||
/// The baseline and the user's turns and mirrors, collapsed into one.
|
||||
///
|
||||
/// Everything that renders or measures the frame goes through here; only
|
||||
/// the panel's readout and the sidecar read the user's values raw.
|
||||
///
|
||||
/// The composition is the group law, not an addition. Writing a transform
|
||||
/// as `mirrors ∘ turn`, applying the user's and then the baseline's gives
|
||||
/// `Db · Rtb · Du · Rtu`, and conjugating `Du` past `Rtb` swaps its two
|
||||
/// axes when that turn is odd — which is exactly the case that a naive
|
||||
/// "add the turns, or the flags" gets wrong, and gets wrong silently,
|
||||
/// since the result is still a valid-looking orientation.
|
||||
fn effective(&self) -> (u8, bool, bool) {
|
||||
let b = self.baseline;
|
||||
// The user's mirrors, seen from the far side of the baseline's turn.
|
||||
let (ux, uy) = if b.swaps_axes() {
|
||||
(self.flip_v, self.flip_h)
|
||||
} else {
|
||||
(self.flip_h, self.flip_v)
|
||||
};
|
||||
(
|
||||
(b.quarter_turns + self.quarter_turns) % 4,
|
||||
b.flip_h != ux,
|
||||
b.flip_v != uy,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this stage currently changes the image.
|
||||
///
|
||||
/// The same contract the operations honour: neutral framing contributes
|
||||
/// nothing to the generated shader, so an uncropped image reads its
|
||||
/// pixels through the identity map exactly as it did before this existed.
|
||||
pub fn is_active(&self) -> bool {
|
||||
let (turns, flip_h, flip_v) = self.effective();
|
||||
self.angle != 0.0
|
||||
|| self.quarter_turns != 0
|
||||
|| self.flip_h
|
||||
|| self.flip_v
|
||||
// Effective, not the user's: a file stored sideways needs the
|
||||
// prologue emitted even on an untouched image, or it renders
|
||||
// through the identity map and lies on its side.
|
||||
|| turns != 0
|
||||
|| flip_h
|
||||
|| flip_v
|
||||
|| !self.crop.is_full()
|
||||
// Zoom is not an edit, but it *is* a coordinate map: without the
|
||||
// prologue the shader samples the whole frame and the zoom does
|
||||
@@ -298,6 +362,11 @@ impl Framing {
|
||||
/// also requires. This answers "would the exported file differ", which
|
||||
/// zoom must never affect — it is what tells the interface whether there
|
||||
/// are edits worth saving.
|
||||
///
|
||||
/// The baseline is excluded on purpose. Opening a portrait frame that the
|
||||
/// camera stored sideways must not light the modified dot, enable the
|
||||
/// reset, or persuade the sidecar there is something to save — nothing was
|
||||
/// edited, the file was merely read correctly.
|
||||
pub fn edits_image(&self) -> bool {
|
||||
self.angle != 0.0
|
||||
|| self.quarter_turns != 0
|
||||
@@ -306,9 +375,9 @@ impl Framing {
|
||||
|| !self.crop.is_full()
|
||||
}
|
||||
|
||||
/// Whether the axes are swapped — a 90° or 270° turn.
|
||||
/// Whether the axes are swapped — a 90° or 270° turn, baseline included.
|
||||
fn swaps_axes(&self) -> bool {
|
||||
self.quarter_turns % 2 == 1
|
||||
self.effective().0 % 2 == 1
|
||||
}
|
||||
|
||||
/// Whether the map puts output pixels between source pixels.
|
||||
@@ -375,8 +444,17 @@ impl Framing {
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear every framing edit.
|
||||
///
|
||||
/// The baseline survives, because it was never an edit. "Reset" means
|
||||
/// *the file as it is*, and the file is upright — so this returns the
|
||||
/// photograph to how the camera meant it to be seen rather than to how
|
||||
/// the sensor happened to be scanned.
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
*self = Self {
|
||||
baseline: self.baseline,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
/// The output size this framing produces from a source of `(w, h)`.
|
||||
@@ -571,12 +649,17 @@ impl Framing {
|
||||
);
|
||||
}
|
||||
|
||||
if self.quarter_turns != 0 {
|
||||
// The user's turns and mirrors composed with the file's stored
|
||||
// orientation. One permutation covers both, so honouring the EXIF tag
|
||||
// adds no per-pixel work over an untagged file.
|
||||
let (turns, flip_h, flip_v) = self.effective();
|
||||
|
||||
if turns != 0 {
|
||||
// An exact coordinate permutation rather than a rotation through
|
||||
// the matrix above, which would resample a transform that has an
|
||||
// exact answer. Applied to `p`, so the aspect scaling has to be
|
||||
// undone and reapplied across the swap.
|
||||
let permutation = match self.quarter_turns {
|
||||
let permutation = match turns {
|
||||
1 => " p = vec2<f32>(p.y * aspect.x, -p.x / aspect.x);",
|
||||
2 => " p = -p;",
|
||||
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / aspect.x);",
|
||||
@@ -587,14 +670,14 @@ impl Framing {
|
||||
// {}° clockwise — an exact permutation, so nothing is resampled.
|
||||
{permutation}
|
||||
",
|
||||
u32::from(self.quarter_turns) * 90
|
||||
u32::from(turns) * 90
|
||||
);
|
||||
}
|
||||
|
||||
if self.flip_h {
|
||||
if flip_h {
|
||||
s.push_str(" p.x = -p.x;\n");
|
||||
}
|
||||
if self.flip_v {
|
||||
if flip_v {
|
||||
s.push_str(" p.y = -p.y;\n");
|
||||
}
|
||||
|
||||
@@ -622,11 +705,15 @@ impl Framing {
|
||||
/// any zoom and cleared by none, so a wheel notch is still a uniform
|
||||
/// upload rather than a shader build.
|
||||
pub fn structure_key(&self) -> u64 {
|
||||
// Effective throughout, because this identifies the *generated WGSL*
|
||||
// and that is what the prologue emits. Two images differing only in
|
||||
// their stored orientation must not share a compiled pipeline.
|
||||
let (turns, flip_h, flip_v) = self.effective();
|
||||
u64::from(!self.crop.is_full())
|
||||
| u64::from(self.angle != 0.0) << 1
|
||||
| u64::from(self.flip_h) << 2
|
||||
| u64::from(self.flip_v) << 3
|
||||
| u64::from(self.quarter_turns) << 4
|
||||
| u64::from(flip_h) << 2
|
||||
| u64::from(flip_v) << 3
|
||||
| u64::from(turns) << 4
|
||||
| u64::from(self.is_active()) << 6
|
||||
}
|
||||
}
|
||||
@@ -639,6 +726,159 @@ pub const FRAMING_UNIFORM_FIELDS: usize = 8;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_types::Orientation;
|
||||
|
||||
/// The group law, checked against pixels rather than against itself.
|
||||
///
|
||||
/// [`Framing::effective`] claims a baseline and a user rotation collapse
|
||||
/// into one turn plus two mirrors. The claim is only worth anything if the
|
||||
/// collapsed transform moves every pixel where the two separate ones
|
||||
/// would, so that is what this asserts, over all 8 × 16 pairs.
|
||||
///
|
||||
/// The case that fails without the conjugation swap is any odd baseline
|
||||
/// turn combined with a user flip — a phone portrait that the user then
|
||||
/// mirrors. Naive flag-ORing renders it mirrored about the wrong axis,
|
||||
/// which still looks like a photograph.
|
||||
#[test]
|
||||
fn a_baseline_and_a_user_rotation_compose_into_one_permutation() {
|
||||
// Non-square and coprime, so no accidental symmetry hides an error.
|
||||
const SW: u32 = 5;
|
||||
const SH: u32 = 3;
|
||||
|
||||
for tag in 1..=8u16 {
|
||||
let baseline = Orientation::from_exif(tag);
|
||||
let (ow, oh) = baseline.oriented_size(SW, SH);
|
||||
|
||||
for user_turns in 0..4u8 {
|
||||
for user_flip_h in [false, true] {
|
||||
for user_flip_v in [false, true] {
|
||||
let user = Orientation {
|
||||
quarter_turns: user_turns,
|
||||
flip_h: user_flip_h,
|
||||
flip_v: user_flip_v,
|
||||
};
|
||||
|
||||
let mut f = Framing::new();
|
||||
f.set_baseline(baseline);
|
||||
f.rotate_quarters(i32::from(user_turns));
|
||||
f.set_param(FLIP_H, f32::from(u8::from(user_flip_h)));
|
||||
f.set_param(FLIP_V, f32::from(u8::from(user_flip_v)));
|
||||
|
||||
let (t, fh, fv) = f.effective();
|
||||
let combined = Orientation {
|
||||
quarter_turns: t,
|
||||
flip_h: fh,
|
||||
flip_v: fv,
|
||||
};
|
||||
|
||||
// The output size the composed transform produces must
|
||||
// be the one the two stages produce in sequence.
|
||||
let (dw, dh) = user.oriented_size(ow, oh);
|
||||
assert_eq!(
|
||||
f.output_size(SW, SH),
|
||||
(dw, dh),
|
||||
"tag {tag}, user {user_turns}/{user_flip_h}/{user_flip_v}"
|
||||
);
|
||||
|
||||
for y in 0..dh {
|
||||
for x in 0..dw {
|
||||
// Display -> oriented -> stored, the long way.
|
||||
let (ox, oy) = user.source_pixel(x, y, dw, dh);
|
||||
let stepwise = baseline.source_pixel(ox, oy, ow, oh);
|
||||
// Display -> stored, in one permutation.
|
||||
let fused = combined.source_pixel(x, y, dw, dh);
|
||||
assert_eq!(
|
||||
stepwise, fused,
|
||||
"tag {tag}, user {user_turns}/{user_flip_h}/{user_flip_v} \
|
||||
at ({x},{y})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sideways_file_opens_upright_without_counting_as_an_edit() {
|
||||
// The whole point of the baseline. A phone portrait is 4000x6000 on
|
||||
// screen and 6000x4000 on disk, and none of that is the user's doing:
|
||||
// no modified dot, nothing for the sidecar to save.
|
||||
let mut f = Framing::new();
|
||||
f.set_baseline(Orientation::from_exif(6));
|
||||
|
||||
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
|
||||
assert_eq!(f.output_size_uncropped(6000, 4000), (4000, 6000));
|
||||
assert!(!f.edits_image(), "reading the file is not editing it");
|
||||
// The prologue must still be emitted, or the turn never happens.
|
||||
assert!(f.is_active());
|
||||
// And the panel reads back neutral, because the user turned nothing.
|
||||
assert_eq!(f.param(ROTATION), 0.0);
|
||||
assert_eq!(f.param(FLIP_H), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_the_file_as_it_is_not_to_the_sensor_as_it_scanned() {
|
||||
// Reset means "undo my edits". Dropping the baseline here would lay
|
||||
// every portrait frame back on its side, which reads as a bug in
|
||||
// reset rather than as the deliberate act it would be.
|
||||
let mut f = Framing::new();
|
||||
f.set_baseline(Orientation::from_exif(8));
|
||||
f.rotate_quarters(1);
|
||||
f.set_param(ANGLE, -1.5);
|
||||
f.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
|
||||
f.reset();
|
||||
|
||||
assert_eq!(f.baseline(), Orientation::from_exif(8));
|
||||
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
|
||||
assert!(!f.edits_image());
|
||||
assert_eq!(f.param(ROTATION), 0.0);
|
||||
assert_eq!(f.angle(), 0.0);
|
||||
assert!(f.crop().is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_turn_lands_where_it_would_on_an_untagged_file() {
|
||||
// A quarter turn is a quarter turn: whatever the file's baseline, one
|
||||
// press of the button must move the image by 90°, and four must
|
||||
// return it. Otherwise the control means different things on portrait
|
||||
// and landscape files.
|
||||
for tag in 1..=8u16 {
|
||||
let mut f = Framing::new();
|
||||
f.set_baseline(Orientation::from_exif(tag));
|
||||
let upright = f.output_size(6000, 4000);
|
||||
|
||||
f.rotate_quarters(1);
|
||||
let (w, h) = f.output_size(6000, 4000);
|
||||
assert_eq!((w, h), (upright.1, upright.0), "tag {tag}");
|
||||
|
||||
f.rotate_quarters(3);
|
||||
assert_eq!(f.output_size(6000, 4000), upright, "tag {tag}");
|
||||
assert!(!f.edits_image(), "tag {tag}: four turns is back to neutral");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_orientation_reaches_the_shader_and_the_pipeline_cache() {
|
||||
// A neutral graph on a sideways file must not compile the neutral
|
||||
// prologue — that is the path where the tag is read, recorded, and
|
||||
// then silently ignored because nothing asked for a turn.
|
||||
let plain = Framing::new();
|
||||
let mut sideways = Framing::new();
|
||||
sideways.set_baseline(Orientation::from_exif(6));
|
||||
|
||||
assert_ne!(plain.structure_key(), sideways.structure_key());
|
||||
assert!(sideways.wgsl_prologue().contains("90° clockwise"));
|
||||
// Still exact: an orientation is a permutation, never a resample.
|
||||
assert!(!sideways.needs_interpolation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_framing_is_neutral() {
|
||||
|
||||
Reference in New Issue
Block a user