From 489465faf0a488fd7f0cb86cc170752409564306 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 16 Aug 2026 15:37:05 +0200 Subject: [PATCH] Show the photograph the way it was taken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/dr-decode/src/lib.rs | 56 ++++++- core/dr-decode/src/locate.rs | 87 +++++++++++ core/dr-decode/src/preview.rs | 108 +++++++++++++ core/dr-pipeline/src/framing.rs | 268 ++++++++++++++++++++++++++++++-- core/dr-pipeline/src/graph.rs | 9 ++ core/dr-pipeline/src/sidecar.rs | 42 +++++ core/dr-types/src/lib.rs | 223 ++++++++++++++++++++++++++ docs/requirements.md | 22 +++ docs/traceability.md | 43 ++--- ui/dr-ui/src/develop.rs | 38 ++++- ui/dr-ui/src/lib.rs | 12 +- ui/dr-ui/src/library.rs | 15 ++ 12 files changed, 875 insertions(+), 48 deletions(-) diff --git a/core/dr-decode/src/lib.rs b/core/dr-decode/src/lib.rs index cdb2c41..a6a5cbc 100644 --- a/core/dr-decode/src/lib.rs +++ b/core/dr-decode/src/lib.rs @@ -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, pub height: Option, + /// 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, /// 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 { 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 { // 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 { + 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 diff --git a/core/dr-decode/src/locate.rs b/core/dr-decode/src/locate.rs index ef2ca8e..fdc7d15 100644 --- a/core/dr-decode/src/locate.rs +++ b/core/dr-decode/src/locate.rs @@ -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 { + // 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( diff --git a/core/dr-decode/src/preview.rs b/core/dr-decode/src/preview.rs index fb51c18..ecc164e 100644 --- a/core/dr-decode/src/preview.rs +++ b/core/dr-decode/src/preview.rs @@ -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 { 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 diff --git a/core/dr-pipeline/src/framing.rs b/core/dr-pipeline/src/framing.rs index 52e0308..3760ff3 100644 --- a/core/dr-pipeline/src/framing.rs +++ b/core/dr-pipeline/src/framing.rs @@ -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(p.y * aspect.x, -p.x / aspect.x);", 2 => " p = -p;", _ => " p = vec2(-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() { diff --git a/core/dr-pipeline/src/graph.rs b/core/dr-pipeline/src/graph.rs index 523f906..62c082a 100644 --- a/core/dr-pipeline/src/graph.rs +++ b/core/dr-pipeline/src/graph.rs @@ -268,6 +268,15 @@ impl EditGraph { self.framing.rotate_quarters(turns); } + /// Record how the file stored its pixels, from its EXIF orientation. + /// + /// Set when the image is opened and never by an edit — see + /// [`crate::framing::Framing::set_baseline`]. Survives [`Self::reset`], + /// so it is safe to call before restoring a sidecar. + pub fn set_orientation(&mut self, orientation: dr_types::Orientation) { + self.framing.set_baseline(orientation); + } + /// Whether any operation, or the framing, currently changes the image. /// /// Asks the framing whether it *edits*, not whether it is active: zoom diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 55eca61..3f3e4ee 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -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 diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 9d9db7b..342861e 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -269,6 +269,114 @@ pub enum Availability { Offline, } +/// TRACES: FR-DEV-3h +/// How a file's stored pixels sit relative to how the photograph should be +/// seen — EXIF tag `0x0112`. +/// +/// **This is not an edit.** A camera held sideways writes its sensor rows the +/// way it always does and records the fact in a tag; the pixels are simply +/// not in reading order yet. Honouring the tag is part of reading the file +/// correctly, exactly as [`crate::Format`] is — the same standing as a RAW's +/// masked-photosite crop. So it belongs to the *source*, ahead of anything +/// the user decides, and resetting a develop session must not undo it. +/// +/// Stored as the transform the renderer actually applies rather than as the +/// raw tag: eight tag values collapse to a quarter turn plus mirrors, which +/// is the form both the shader prologue and the CPU thumbnail path want, and +/// keeping the tag around would mean re-deriving it at each of them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Orientation { + /// Clockwise quarter turns to apply for display, 0..=3. + pub quarter_turns: u8, + /// Mirror about the vertical centre line, applied after the turn. + pub flip_h: bool, + /// Mirror about the horizontal centre line, applied after the turn. + pub flip_v: bool, +} + +impl Orientation { + /// Stored in reading order — the tag absent, or saying so. + pub const NORMAL: Self = Self { + quarter_turns: 0, + flip_h: false, + flip_v: false, + }; + + /// Read an EXIF orientation tag. + /// + /// The eight values are named by where the stored image's first row and + /// first column end up in the displayed one ("right, top" and so on). + /// Anything outside 1..=8 — and 0, which some bodies write for "unknown" — + /// is [`Self::NORMAL`]: a file that cannot say how it is oriented is far + /// more likely to be upright than to be any particular rotation, and + /// guessing turns a missing tag into a visibly wrong image. + pub fn from_exif(tag: u16) -> Self { + let (quarter_turns, flip_h, flip_v) = match tag { + 2 => (0, true, false), + 3 => (2, false, false), + 4 => (0, false, true), + 5 => (3, true, false), + 6 => (1, false, false), + 7 => (1, true, false), + 8 => (3, false, false), + _ => (0, false, false), + }; + Self { + quarter_turns, + flip_h, + flip_v, + } + } + + /// Whether the file is already in reading order. + pub fn is_normal(self) -> bool { + self == Self::NORMAL + } + + /// Whether displaying this swaps width and height. + pub fn swaps_axes(self) -> bool { + self.quarter_turns % 2 == 1 + } + + /// The stored size seen the right way up. + pub fn oriented_size(self, width: u32, height: u32) -> (u32, u32) { + if self.swaps_axes() { + (height, width) + } else { + (width, height) + } + } + + /// Where a displayed pixel comes from in the stored image. + /// + /// `(dw, dh)` are the *displayed* dimensions — see + /// [`Self::oriented_size`]. Expressed as a backward map because that is + /// what both consumers need: a CPU rewrite fills each destination pixel + /// once, and the shader prologue asks the same question per output pixel. + /// + /// The turn happens first and the mirrors after it, in the stored image's + /// own axes. That ordering is not a detail — it is the one `Framing` + /// composes against, and reversing it swaps cases 5 and 7 (the two + /// diagonal mirrors) for each other, which renders as a 180° error rather + /// than as anything obviously wrong. + pub fn source_pixel(self, x: u32, y: u32, dw: u32, dh: u32) -> (u32, u32) { + let (sw, sh) = self.oriented_size(dw, dh); + let (mut sx, mut sy) = match self.quarter_turns { + 1 => (y, dw - 1 - x), + 2 => (dw - 1 - x, dh - 1 - y), + 3 => (dh - 1 - y, x), + _ => (x, y), + }; + if self.flip_h { + sx = sw - 1 - sx; + } + if self.flip_v { + sy = sh - 1 - sy; + } + (sx, sy) + } +} + /// An opaque change-validator for a remote entry (an ETag, or an mtime where /// no ETag exists). /// @@ -311,6 +419,121 @@ pub enum SourceError { mod tests { use super::*; + /// The eight tag values, checked against what each one is *named* for. + /// + /// EXIF names an orientation by where the stored image's first row and + /// first column land in the displayed one — "right, top" for 6. Asserting + /// against that wording rather than against a rotation count is the only + /// check that catches a sign slip, because five of the eight cases are + /// each other's rotations and a wrong one still produces a plausible + /// picture. + #[test] + fn exif_orientations_put_the_first_row_and_column_where_they_are_named() { + // A 2x3 stored image, so a swap is visible in the dimensions and the + // corners are all distinct. Stored (col, row). + // + // (row, column) each value claims for the displayed image: + let cases: [(u16, (&str, &str)); 8] = [ + (1, ("top", "left")), + (2, ("top", "right")), + (3, ("bottom", "right")), + (4, ("bottom", "left")), + (5, ("left", "top")), + (6, ("right", "top")), + (7, ("right", "bottom")), + (8, ("left", "bottom")), + ]; + + for (tag, (row_goes, col_goes)) in cases { + let o = Orientation::from_exif(tag); + let (dw, dh) = o.oriented_size(2, 3); + + // Where does stored row 0 end up? Find the displayed edge whose + // every pixel reads from row 0. + let row0 = |edge: &str| -> bool { + match edge { + "top" => (0..dw).all(|x| o.source_pixel(x, 0, dw, dh).1 == 0), + "bottom" => (0..dw).all(|x| o.source_pixel(x, dh - 1, dw, dh).1 == 0), + "left" => (0..dh).all(|y| o.source_pixel(0, y, dw, dh).1 == 0), + _ => (0..dh).all(|y| o.source_pixel(dw - 1, y, dw, dh).1 == 0), + } + }; + let col0 = |edge: &str| -> bool { + match edge { + "top" => (0..dw).all(|x| o.source_pixel(x, 0, dw, dh).0 == 0), + "bottom" => (0..dw).all(|x| o.source_pixel(x, dh - 1, dw, dh).0 == 0), + "left" => (0..dh).all(|y| o.source_pixel(0, y, dw, dh).0 == 0), + _ => (0..dh).all(|y| o.source_pixel(dw - 1, y, dw, dh).0 == 0), + } + }; + + assert!( + row0(row_goes), + "tag {tag}: row 0 should be at the {row_goes}" + ); + assert!( + col0(col_goes), + "tag {tag}: col 0 should be at the {col_goes}" + ); + } + } + + #[test] + fn every_oriented_pixel_maps_to_a_distinct_source_pixel() { + // The map is a permutation for all eight values: nothing may be + // dropped or read twice, which is what makes a quarter turn lossless. + for tag in 1..=8u16 { + let o = Orientation::from_exif(tag); + let (dw, dh) = o.oriented_size(5, 3); + let mut seen = BTreeSet::new(); + for y in 0..dh { + for x in 0..dw { + let (sx, sy) = o.source_pixel(x, y, dw, dh); + assert!(sx < 5 && sy < 3, "tag {tag}: ({sx},{sy}) out of bounds"); + assert!(seen.insert((sx, sy)), "tag {tag}: read ({sx},{sy}) twice"); + } + } + assert_eq!(seen.len(), 15, "tag {tag}"); + } + } + + #[test] + fn an_absent_or_nonsense_tag_leaves_the_image_alone() { + // A file that cannot say how it is oriented is far likelier to be + // upright than to be any particular rotation. + for tag in [0, 9, 65535] { + assert!(Orientation::from_exif(tag).is_normal(), "tag {tag}"); + } + assert!(Orientation::default().is_normal()); + assert!(Orientation::from_exif(1).is_normal()); + assert!(!Orientation::from_exif(6).is_normal()); + } + + #[test] + fn only_the_quarter_turns_swap_the_axes() { + assert_eq!( + Orientation::from_exif(6).oriented_size(6000, 4000), + (4000, 6000) + ); + assert_eq!( + Orientation::from_exif(8).oriented_size(6000, 4000), + (4000, 6000) + ); + assert_eq!( + Orientation::from_exif(5).oriented_size(6000, 4000), + (4000, 6000) + ); + // A mirror is not a turn: the shape is unchanged. + assert_eq!( + Orientation::from_exif(2).oriented_size(6000, 4000), + (6000, 4000) + ); + assert_eq!( + Orientation::from_exif(3).oriented_size(6000, 4000), + (6000, 4000) + ); + } + #[test] fn display_name_handles_paths_and_saf_ids() { let local = SourceRef::Local { diff --git a/docs/requirements.md b/docs/requirements.md index b18f1cf..4d47e86 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -338,6 +338,28 @@ into a new pipeline than to retrofit, and the same component attacks the X-Trans Inference is **local only** — no cloud, no telemetry (NFR-SEC-4). The stage is optional at runtime and its absence degrades gracefully. +**FR-DEV-3h — Stored orientation is honoured, not edited.** An image shall be shown the way the +photograph was taken, from its EXIF orientation tag (`0x0112`), everywhere it appears: the grid's +thumbnails, the develop canvas, and the read-only preview shown when no decoder can open the file. + +The tag shall be applied as a property of **reading the file**, at the same standing as a RAW's +masked-photosite crop (FR-RAW-3) — never as an edit. Concretely: + +- Opening a frame the camera stored sideways shall not mark it modified, shall not enable the + framing reset, and shall write nothing to its sidecar. +- "Reset framing" shall return the image to *upright*, not to the sensor's scan order. +- A sidecar shall never carry the orientation. Edits are shared between devices and bodies + (FR-NC-9); one camera's sensor scan must not be applied to another's file. +- A user's own quarter turns compose *on top* of it, so one press of the rotate button moves the + image by 90° whatever the file's baseline. + +A file carrying no tag, or a value outside 1..=8, is displayed as stored. Guessing would turn a +missing tag into a visibly wrong image, and most files have no tag. + +*Acceptance:* a portrait frame from a phone or a body held sideways appears upright in the grid and +in develop with no user action, and its sidecar is byte-identical to that of the same frame shot in +landscape. + **FR-DEV-4 — Ordered, GPU-resident execution.** The pipeline executes as a sequence of GPU compute stages. Intermediate results remain in GPU memory between stages. **Processed pixels shall reach the display without a CPU round-trip.** *(This is a hard architectural constraint — diff --git a/docs/traceability.md b/docs/traceability.md index 537780a..38bd1b1 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -10,16 +10,16 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| | Source files scanned | 93 | -| TRACES tags found | 137 | -| Requirements defined | 150 | -| Requirements covered | 71 | -| **Coverage** | **47.3%** (71/150) | +| TRACES tags found | 141 | +| Requirements defined | 151 | +| Requirements covered | 72 | +| **Coverage** | **47.7%** (72/151) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 55 | 96 | +| FR | 56 | 97 | | NFR | 14 | 48 | | R | 2 | 6 | @@ -36,25 +36,26 @@ _None._ | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:185`](../core/dr-types/src/lib.rs#L185), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | | FR-CAT-11 | [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:366`](../core/dr-sync-nextcloud/src/lib.rs#L366), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:341`](../core/dr-thumbs/src/lib.rs#L341), [`ui/dr-ui/src/collections_ui.rs:1191`](../ui/dr-ui/src/collections_ui.rs#L1191), [`ui/dr-ui/src/collections_ui.rs:730`](../ui/dr-ui/src/collections_ui.rs#L730), [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150), [`ui/dr-ui/src/library.rs:167`](../ui/dr-ui/src/library.rs#L167), [`ui/dr-ui/src/library.rs:2005`](../ui/dr-ui/src/library.rs#L2005), [`ui/dr-ui/src/library.rs:2037`](../ui/dr-ui/src/library.rs#L2037), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:436`](../ui/dr-ui/src/library_ui.rs#L436), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:458`](../ui/dr-ui/ui/collections.slint#L458) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:258`](../core/dr-catalog/src/schema.rs#L258), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:366`](../core/dr-sync-nextcloud/src/lib.rs#L366), [`core/dr-sync/src/lib.rs:122`](../core/dr-sync/src/lib.rs#L122), [`core/dr-sync/src/scan.rs:426`](../core/dr-sync/src/scan.rs#L426), [`core/dr-sync/src/scan.rs:57`](../core/dr-sync/src/scan.rs#L57), [`core/dr-thumbs/src/lib.rs:341`](../core/dr-thumbs/src/lib.rs#L341), [`ui/dr-ui/src/collections_ui.rs:1191`](../ui/dr-ui/src/collections_ui.rs#L1191), [`ui/dr-ui/src/collections_ui.rs:730`](../ui/dr-ui/src/collections_ui.rs#L730), [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150), [`ui/dr-ui/src/library.rs:167`](../ui/dr-ui/src/library.rs#L167), [`ui/dr-ui/src/library.rs:2020`](../ui/dr-ui/src/library.rs#L2020), [`ui/dr-ui/src/library.rs:2052`](../ui/dr-ui/src/library.rs#L2052), [`ui/dr-ui/src/library_ui.rs:112`](../ui/dr-ui/src/library_ui.rs#L112), [`ui/dr-ui/src/library_ui.rs:436`](../ui/dr-ui/src/library_ui.rs#L436), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:458`](../ui/dr-ui/ui/collections.slint#L458) | | FR-CAT-1a | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) | | FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) | | FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | -| FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:231`](../core/dr-decode/src/lib.rs#L231), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125) | +| FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:240`](../core/dr-decode/src/lib.rs#L240), [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125) | | FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177) | | FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) | -| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`ui/dr-ui/src/library.rs:136`](../ui/dr-ui/src/library.rs#L136), [`ui/dr-ui/src/library.rs:194`](../ui/dr-ui/src/library.rs#L194), [`ui/dr-ui/src/library.rs:2125`](../ui/dr-ui/src/library.rs#L2125), [`ui/dr-ui/src/library.rs:994`](../ui/dr-ui/src/library.rs#L994), [`ui/dr-ui/src/library_ui.rs:1240`](../ui/dr-ui/src/library_ui.rs#L1240), [`ui/dr-ui/src/library_ui.rs:1391`](../ui/dr-ui/src/library_ui.rs#L1391), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:1682`](../ui/dr-ui/src/library_ui.rs#L1682), [`ui/dr-ui/src/library_ui.rs:1756`](../ui/dr-ui/src/library_ui.rs#L1756), [`ui/dr-ui/src/library_ui.rs:1870`](../ui/dr-ui/src/library_ui.rs#L1870), [`ui/dr-ui/src/library_ui.rs:2717`](../ui/dr-ui/src/library_ui.rs#L2717), [`ui/dr-ui/src/library_ui.rs:2745`](../ui/dr-ui/src/library_ui.rs#L2745), [`ui/dr-ui/src/library_ui.rs:283`](../ui/dr-ui/src/library_ui.rs#L283), [`ui/dr-ui/src/library_ui.rs:936`](../ui/dr-ui/src/library_ui.rs#L936), [`ui/dr-ui/src/library_ui.rs:978`](../ui/dr-ui/src/library_ui.rs#L978) | -| FR-CULL-1 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) | -| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123) | +| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`ui/dr-ui/src/library.rs:136`](../ui/dr-ui/src/library.rs#L136), [`ui/dr-ui/src/library.rs:194`](../ui/dr-ui/src/library.rs#L194), [`ui/dr-ui/src/library.rs:2140`](../ui/dr-ui/src/library.rs#L2140), [`ui/dr-ui/src/library.rs:994`](../ui/dr-ui/src/library.rs#L994), [`ui/dr-ui/src/library_ui.rs:1240`](../ui/dr-ui/src/library_ui.rs#L1240), [`ui/dr-ui/src/library_ui.rs:1391`](../ui/dr-ui/src/library_ui.rs#L1391), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:1682`](../ui/dr-ui/src/library_ui.rs#L1682), [`ui/dr-ui/src/library_ui.rs:1756`](../ui/dr-ui/src/library_ui.rs#L1756), [`ui/dr-ui/src/library_ui.rs:1870`](../ui/dr-ui/src/library_ui.rs#L1870), [`ui/dr-ui/src/library_ui.rs:2717`](../ui/dr-ui/src/library_ui.rs#L2717), [`ui/dr-ui/src/library_ui.rs:2745`](../ui/dr-ui/src/library_ui.rs#L2745), [`ui/dr-ui/src/library_ui.rs:283`](../ui/dr-ui/src/library_ui.rs#L283), [`ui/dr-ui/src/library_ui.rs:936`](../ui/dr-ui/src/library_ui.rs#L936), [`ui/dr-ui/src/library_ui.rs:978`](../ui/dr-ui/src/library_ui.rs#L978) | +| FR-CULL-1 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | +| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) | | FR-DEV-3 | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) | | FR-DEV-3a | [`core/dr-pipeline/src/descriptor.rs:91`](../core/dr-pipeline/src/descriptor.rs#L91), [`core/dr-pipeline/src/graph.rs:138`](../core/dr-pipeline/src/graph.rs#L138), [`core/dr-pipeline/src/graph.rs:15`](../core/dr-pipeline/src/graph.rs#L15), [`core/dr-pipeline/src/graph.rs:39`](../core/dr-pipeline/src/graph.rs#L39), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:91`](../core/dr-pipeline/src/descriptor.rs#L91), [`core/dr-pipeline/src/graph.rs:39`](../core/dr-pipeline/src/graph.rs#L39), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) | | FR-DEV-3c | [`core/dr-pipeline/src/graph.rs:138`](../core/dr-pipeline/src/graph.rs#L138) | | FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) | -| FR-DEV-3e | [`core/dr-decode/src/lib.rs:442`](../core/dr-decode/src/lib.rs#L442), [`core/dr-decode/src/lib.rs:562`](../core/dr-decode/src/lib.rs#L562) | +| FR-DEV-3e | [`core/dr-decode/src/lib.rs:492`](../core/dr-decode/src/lib.rs#L492), [`core/dr-decode/src/lib.rs:612`](../core/dr-decode/src/lib.rs#L612) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:307`](../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-types/src/lib.rs:272`](../core/dr-types/src/lib.rs#L272) | | FR-DEV-4 | [`core/dr-gpu/src/lib.rs:123`](../core/dr-gpu/src/lib.rs#L123) | | FR-DSP-1 | [`ui/dr-ui/src/lib.rs:44`](../ui/dr-ui/src/lib.rs#L44) | | FR-EXP-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | @@ -64,14 +65,14 @@ _None._ | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | | FR-EXP-6 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-8 | [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | -| FR-EXP-9 | [`core/dr-decode/src/lib.rs:359`](../core/dr-decode/src/lib.rs#L359) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409) | | FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) | | FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:811`](../core/dr-sync-nextcloud/src/lib.rs#L811), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) | | FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:161`](../core/dr-decode/src/preview.rs#L161), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) | | FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1315`](../ui/dr-ui/src/lib.rs#L1315), [`ui/dr-ui/src/lib.rs:875`](../ui/dr-ui/src/lib.rs#L875), [`ui/dr-ui/src/library.rs:813`](../ui/dr-ui/src/library.rs#L813), [`ui/dr-ui/src/library.rs:836`](../ui/dr-ui/src/library.rs#L836), [`ui/dr-ui/src/library.rs:994`](../ui/dr-ui/src/library.rs#L994), [`ui/dr-ui/src/library_ui.rs:1041`](../ui/dr-ui/src/library_ui.rs#L1041), [`ui/dr-ui/src/library_ui.rs:173`](../ui/dr-ui/src/library_ui.rs#L173), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:193`](../ui/dr-ui/src/library_ui.rs#L193), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:252`](../ui/dr-ui/src/library_ui.rs#L252), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:294`](../ui/dr-ui/src/library_ui.rs#L294), [`ui/dr-ui/src/library_ui.rs:309`](../ui/dr-ui/src/library_ui.rs#L309), [`ui/dr-ui/src/library_ui.rs:340`](../ui/dr-ui/src/library_ui.rs#L340), [`ui/dr-ui/src/library_ui.rs:699`](../ui/dr-ui/src/library_ui.rs#L699), [`ui/dr-ui/src/library_ui.rs:800`](../ui/dr-ui/src/library_ui.rs#L800), [`ui/dr-ui/src/library_ui.rs:903`](../ui/dr-ui/src/library_ui.rs#L903), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:799`](../ui/dr-ui/ui/library.slint#L799) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1323`](../ui/dr-ui/src/lib.rs#L1323), [`ui/dr-ui/src/lib.rs:883`](../ui/dr-ui/src/lib.rs#L883), [`ui/dr-ui/src/library.rs:813`](../ui/dr-ui/src/library.rs#L813), [`ui/dr-ui/src/library.rs:836`](../ui/dr-ui/src/library.rs#L836), [`ui/dr-ui/src/library.rs:994`](../ui/dr-ui/src/library.rs#L994), [`ui/dr-ui/src/library_ui.rs:1041`](../ui/dr-ui/src/library_ui.rs#L1041), [`ui/dr-ui/src/library_ui.rs:173`](../ui/dr-ui/src/library_ui.rs#L173), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:193`](../ui/dr-ui/src/library_ui.rs#L193), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:252`](../ui/dr-ui/src/library_ui.rs#L252), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:294`](../ui/dr-ui/src/library_ui.rs#L294), [`ui/dr-ui/src/library_ui.rs:309`](../ui/dr-ui/src/library_ui.rs#L309), [`ui/dr-ui/src/library_ui.rs:340`](../ui/dr-ui/src/library_ui.rs#L340), [`ui/dr-ui/src/library_ui.rs:699`](../ui/dr-ui/src/library_ui.rs#L699), [`ui/dr-ui/src/library_ui.rs:800`](../ui/dr-ui/src/library_ui.rs#L800), [`ui/dr-ui/src/library_ui.rs:903`](../ui/dr-ui/src/library_ui.rs#L903), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:799`](../ui/dr-ui/ui/library.slint#L799) | | FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`core/dr-types/src/lib.rs:186`](../core/dr-types/src/lib.rs#L186) | | FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) | | FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) | @@ -79,20 +80,20 @@ _None._ | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | -| FR-RAW-1 | [`core/dr-decode/src/lib.rs:189`](../core/dr-decode/src/lib.rs#L189), [`core/dr-types/src/lib.rs:114`](../core/dr-types/src/lib.rs#L114), [`core/dr-types/src/lib.rs:185`](../core/dr-types/src/lib.rs#L185) | -| FR-RAW-3 | [`core/dr-decode/src/lib.rs:359`](../core/dr-decode/src/lib.rs#L359), [`core/dr-decode/src/lib.rs:85`](../core/dr-decode/src/lib.rs#L85) | +| FR-RAW-1 | [`core/dr-decode/src/lib.rs:198`](../core/dr-decode/src/lib.rs#L198), [`core/dr-types/src/lib.rs:114`](../core/dr-types/src/lib.rs#L114), [`core/dr-types/src/lib.rs:185`](../core/dr-types/src/lib.rs#L185) | +| FR-RAW-3 | [`core/dr-decode/src/lib.rs:409`](../core/dr-decode/src/lib.rs#L409), [`core/dr-decode/src/lib.rs:94`](../core/dr-decode/src/lib.rs#L94) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | -| FR-RAW-5 | [`core/dr-decode/src/lib.rs:113`](../core/dr-decode/src/lib.rs#L113) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1397`](../ui/dr-ui/src/lib.rs#L1397), [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) | +| FR-RAW-5 | [`core/dr-decode/src/lib.rs:122`](../core/dr-decode/src/lib.rs#L122) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) | | FR-UI-2 | [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) | | FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-4 | [`ui/dr-ui/ui/app.slint:966`](../ui/dr-ui/ui/app.slint#L966) | -| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1431`](../ui/dr-ui/src/lib.rs#L1431), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1439`](../ui/dr-ui/src/lib.rs#L1439), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) | | NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) | | NFR-P1 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) | -| NFR-P13 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) | +| NFR-P13 | [`core/dr-decode/src/preview.rs:134`](../core/dr-decode/src/preview.rs#L134) | | NFR-P9 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1) | | NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | @@ -107,7 +108,7 @@ _None._ ## Not yet tagged -79 of 150 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +79 of 151 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index e035fda..37b24d1 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -28,10 +28,19 @@ pub struct DevelopSession { impl DevelopSession { /// Demosaic an image and prepare its edit graph. - pub fn open(ctx: &GpuContext, raw: &RawImage) -> Result { + /// + /// `orientation` is the file's EXIF orientation, not an edit: a sensor is + /// scanned the same way whichever way the body was held, so this is what + /// makes a portrait frame open upright. It is fixed for the life of the + /// session and survives a reset. + pub fn open( + ctx: &GpuContext, + raw: &RawImage, + orientation: dr_types::Orientation, + ) -> Result { let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?; let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?; - Ok(Self::with_source(ctx, demosaiced)) + Ok(Self::with_source(ctx, demosaiced, orientation)) } /// Prepare an edit graph over an already-processed RGB image. @@ -49,15 +58,22 @@ impl DevelopSession { rgba: &[u8], width: u32, height: u32, + orientation: dr_types::Orientation, ) -> Result { let source = DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?; - Ok(Self::with_source(ctx, source)) + Ok(Self::with_source(ctx, source, orientation)) } - fn with_source(ctx: &GpuContext, demosaiced: DemosaicedImage) -> Self { + fn with_source( + ctx: &GpuContext, + demosaiced: DemosaicedImage, + orientation: dr_types::Orientation, + ) -> Self { + let mut graph = EditGraph::default_chain(); + graph.set_orientation(orientation); Self { - graph: EditGraph::default_chain(), + graph, demosaiced, adjust: AdjustPass::new(ctx), } @@ -751,7 +767,9 @@ mod tests { rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]); } } - let mut session = DevelopSession::open_rgb(&ctx, &rgba, w, h).expect("session"); + let mut session = + DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL) + .expect("session"); let fitted = session.render(64, 64).expect("fitted render"); session.zoom_about(4.0, 0.5, 0.5); @@ -789,7 +807,9 @@ mod tests { // Bigger than the viewport it is shown in: `fit` scales it down, so // every screen pixel still has several source pixels behind it. let big = vec![128u8; (800 * 800 * 4) as usize]; - let mut session = DevelopSession::open_rgb(&ctx, &big, 800, 800).expect("session"); + let mut session = + DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL) + .expect("session"); assert!( !session.magnifies_source(200, 200), "a downscaled image is not magnified" @@ -808,7 +828,9 @@ mod tests { // Smaller than the viewport: `fit` refuses to upscale, so the render is // 1:1 and unzoomed is exactly the boundary — not past it. let small = vec![128u8; (100 * 100 * 4) as usize]; - let mut session = DevelopSession::open_rgb(&ctx, &small, 100, 100).expect("session"); + let mut session = + DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL) + .expect("session"); assert!( !session.magnifies_source(800, 800), "1:1 is the boundary, not past it — filtering must not flip on a \ diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 42a2542..e60cbc7 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -120,6 +120,10 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result, bytes: &[u8]) -> Result, bytes: &[u8]) -> Result::clone_from_slice( &preview.rgba, diff --git a/ui/dr-ui/src/library.rs b/ui/dr-ui/src/library.rs index ded33d5..33961cc 100644 --- a/ui/dr-ui/src/library.rs +++ b/ui/dr-ui/src/library.rs @@ -1332,6 +1332,13 @@ async fn fetch_one( collect_metadata(&header, req, found_metadata); } + // Read unconditionally, unlike the rest of the EXIF above: `needs_metadata` + // is false once an image has been catalogued, but a thumbnail can still be + // regenerated long after that — a cleared cache, a new size — and a + // thumbnail that came out upright the first time must come out upright + // every time. This is a header walk, not a decode; see `dr_decode::orientation`. + let orientation = dr_decode::orientation(&header).unwrap_or_default(); + // A plain JPEG is its own preview; anything else needs locating. let bytes = if header.starts_with(&[0xFF, 0xD8, 0xFF]) { match backend.get(&id, None).await { @@ -1368,6 +1375,14 @@ async fn fetch_one( Err(e) => return fail(e.to_string()), }; preview.downscale_to(req.thumb_size.edge()); + // Turn it the right way up before it is measured, cached or shown. An + // embedded preview is written in the sensor's orientation, so without this + // every frame shot in portrait lies on its side in the grid — and, because + // the cache is keyed by file and size alone, stays that way. + // + // After the downscale, so the permutation moves thumbnail-sized bytes + // rather than the full preview's. + preview.apply_orientation(orientation); // Persist for next time, and for every other client that syncs the shard. // A store failure is logged and dropped: the pixels are already in hand,