Show the photograph the way it was taken

Nothing read EXIF orientation, so every frame from a body held sideways
lay on its side — in the grid, in develop, and in the read-only preview.

The tag is honoured as part of *reading the file*, at the same standing
as a RAW's masked-photosite crop, never as an edit. It lives as a
baseline on Framing rather than as a starting value for quarter_turns,
which is what keeps four things true: a sideways file opens unmodified,
reset returns it to upright rather than to the sensor's scan order, its
sidecar stays empty, and the rotate button still moves the image 90°
whatever the file underneath it says.

Framing::effective composes the baseline with the user's own turns
through the group law rather than by adding turns and OR-ing flags. The
naive version gets one case wrong — an odd baseline turn plus a user
mirror — and gets it wrong quietly, because the result is still a
plausible orientation. The composition collapses to a single
permutation, so obeying the tag costs nothing per pixel.

dr_decode::orientation is a header-only IFD walk, separate from
metadata() for the reason the entry points are separate at all: the grid
asks once per cell and must not build a rawler decoder to get one tag.
CR3 and RAF fall back to the full read, being neither TIFF nor JPEG.

Written down as FR-DEV-3h.

Known gap: thumbnails cached before this stay sideways. The store is
keyed by file and size, and its shards sync — invalidating them would
have every client re-download 25 MB a shard, which is not this commit's
call to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 15:37:05 +02:00
co-authored by Claude Opus 5
parent b044a8c067
commit 489465faf0
12 changed files with 875 additions and 48 deletions
+53 -3
View File
@@ -23,7 +23,7 @@ pub use preview::{
PREVIEW_PROBE_BYTES, PREVIEW_PROBE_BYTES,
}; };
use dr_types::Format; use dr_types::{Format, Orientation};
/// Capture metadata read from a file header. /// Capture metadata read from a file header.
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq)]
@@ -39,6 +39,15 @@ pub struct Metadata {
/// Full sensor dimensions, before crop. /// Full sensor dimensions, before crop.
pub width: Option<u32>, pub width: Option<u32>,
pub height: Option<u32>, pub height: Option<u32>,
/// How the stored pixels sit relative to how the photograph should be
/// seen (EXIF `0x0112`).
///
/// `None` where the file carries no tag, which is not the same claim as
/// [`Orientation::NORMAL`]: the first says nothing is known, the second
/// says the camera was held level. Callers treat them alike — an unknown
/// orientation is displayed as-is — but keeping them apart means a future
/// "rotate on import" pass can tell a deliberate `1` from a silent gap.
pub orientation: Option<Orientation>,
/// When the shutter fired, as Unix seconds. /// When the shutter fired, as Unix seconds.
/// ///
/// EXIF records wall-clock time with no zone, so this is that reading /// EXIF records wall-clock time with no zone, so this is that reading
@@ -259,6 +268,7 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
focal_length: exif.focal_length.map(|r| r.n as f32 / r.d.max(1) as f32), focal_length: exif.focal_length.map(|r| r.n as f32 / r.d.max(1) as f32),
width: None, width: None,
height: None, height: None,
orientation: exif.orientation.map(Orientation::from_exif),
captured_at: exif captured_at: exif
.date_time_original .date_time_original
.as_deref() .as_deref()
@@ -275,18 +285,58 @@ pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
// comes back empty. These formats *are* TIFF, so the same reader the JPEG // 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 // path uses can find it. Only the missing fields are filled, so rawler
// stays authoritative wherever it did answer. // 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) { 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.captured_offset = out.captured_offset.or(fallback.captured_offset);
out.iso = out.iso.or(fallback.iso); out.iso = out.iso.or(fallback.iso);
out.lens = out.lens.take().or(fallback.lens); out.lens = out.lens.take().or(fallback.lens);
out.orientation = out.orientation.or(fallback.orientation);
} }
} }
Ok(out) Ok(out)
} }
/// TRACES: FR-CAT-5 | FR-DEV-3h
/// Read just the stored orientation, from a file header.
///
/// Separate from [`metadata`] for the reason the four entry points are
/// separate at all (ARCH §3.2): the grid needs this for every cell it draws a
/// thumbnail into, and it already holds the header bytes. Going through
/// `metadata` would put a full rawler decoder construction behind one tag —
/// the same mistake as decoding sensor data to cull.
///
/// This is an IFD walk over bytes already in memory, so it costs effectively
/// nothing on the TIFF-derived formats and on JPEG. The two containers that
/// are neither — Canon's CR3, which is ISO-BMFF, and Fujifilm's RAF — fall
/// back to the full read, because the alternative is showing those bodies'
/// portrait frames on their side.
///
/// `None` means the header carried no orientation, which callers should treat
/// as [`Orientation::NORMAL`] rather than as a failure: most files have no tag.
pub fn orientation(header: &[u8]) -> Option<Orientation> {
let direct = if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
locate::jpeg_metadata(header).ok()
} else {
locate::tiff_metadata(header).ok()
};
if let Some(o) = direct.and_then(|m| m.orientation) {
return Some(o);
}
match probe(header) {
Some(Format::Cr3) | Some(Format::Raf) => metadata(header).ok().and_then(|m| m.orientation),
_ => None,
}
}
/// Parse an EXIF `DateTimeOriginal` into Unix seconds. /// Parse an EXIF `DateTimeOriginal` into Unix seconds.
/// ///
/// The format is `"YYYY:MM:DD HH:MM:SS"` — colons in the date, which is what /// The format is `"YYYY:MM:DD HH:MM:SS"` — colons in the date, which is what
+87
View File
@@ -470,6 +470,11 @@ const EXIF_IFD_POINTER: u16 = 0x8769;
mod exif_tag { mod exif_tag {
pub const MAKE: u16 = 0x010F; pub const MAKE: u16 = 0x010F;
pub const MODEL: u16 = 0x0110; 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. /// When the shutter fired. Absent on scanner output.
pub const DATE_TIME_ORIGINAL: u16 = 0x9003; pub const DATE_TIME_ORIGINAL: u16 = 0x9003;
/// When the file was written. A camera sets both; a **scanner sets only /// 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::ISO => md.iso = r.scalar(e),
exif_tag::PIXEL_X => md.width = r.scalar(e), exif_tag::PIXEL_X => md.width = r.scalar(e),
exif_tag::PIXEL_Y => md.height = 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 => { exif_tag::DATE_TIME_ORIGINAL => {
if let Some(t) = r.ascii(e).as_deref().and_then(crate::parse_exif_datetime) { if let Some(t) = r.ascii(e).as_deref().and_then(crate::parse_exif_datetime) {
md.captured_at = Some(t); md.captured_at = Some(t);
@@ -575,6 +591,77 @@ mod tests {
v 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<u8> {
// 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] #[test]
fn finds_a_jpeg_interchange_preview() { fn finds_a_jpeg_interchange_preview() {
let h = tiff( let h = tiff(
+108
View File
@@ -26,6 +26,44 @@ pub struct Preview {
} }
impl 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. /// 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 /// 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 { mod tests {
use super::*; 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] #[test]
fn size_preference_falls_through_in_order() { fn size_preference_falls_through_in_order() {
// A container missing the requested size must yield the next // A container missing the requested size must yield the next
+254 -14
View File
@@ -185,6 +185,25 @@ pub struct Framing {
quarter_turns: u8, quarter_turns: u8,
flip_h: bool, flip_h: bool,
flip_v: 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, crop: CropRect,
/// Which part of the framed image the viewport is looking at. /// Which part of the framed image the viewport is looking at.
/// ///
@@ -207,6 +226,7 @@ impl Default for Framing {
quarter_turns: 0, quarter_turns: 0,
flip_h: false, flip_h: false,
flip_v: false, flip_v: false,
baseline: dr_types::Orientation::NORMAL,
crop: CropRect::default(), crop: CropRect::default(),
view: 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; 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. /// Whether this stage currently changes the image.
/// ///
/// The same contract the operations honour: neutral framing contributes /// The same contract the operations honour: neutral framing contributes
/// nothing to the generated shader, so an uncropped image reads its /// nothing to the generated shader, so an uncropped image reads its
/// pixels through the identity map exactly as it did before this existed. /// pixels through the identity map exactly as it did before this existed.
pub fn is_active(&self) -> bool { pub fn is_active(&self) -> bool {
let (turns, flip_h, flip_v) = self.effective();
self.angle != 0.0 self.angle != 0.0
|| self.quarter_turns != 0 // Effective, not the user's: a file stored sideways needs the
|| self.flip_h // prologue emitted even on an untouched image, or it renders
|| self.flip_v // through the identity map and lies on its side.
|| turns != 0
|| flip_h
|| flip_v
|| !self.crop.is_full() || !self.crop.is_full()
// Zoom is not an edit, but it *is* a coordinate map: without the // Zoom is not an edit, but it *is* a coordinate map: without the
// prologue the shader samples the whole frame and the zoom does // 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 /// also requires. This answers "would the exported file differ", which
/// zoom must never affect — it is what tells the interface whether there /// zoom must never affect — it is what tells the interface whether there
/// are edits worth saving. /// 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 { pub fn edits_image(&self) -> bool {
self.angle != 0.0 self.angle != 0.0
|| self.quarter_turns != 0 || self.quarter_turns != 0
@@ -306,9 +375,9 @@ impl Framing {
|| !self.crop.is_full() || !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 { fn swaps_axes(&self) -> bool {
self.quarter_turns % 2 == 1 self.effective().0 % 2 == 1
} }
/// Whether the map puts output pixels between source pixels. /// 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) { 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)`. /// 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 // An exact coordinate permutation rather than a rotation through
// the matrix above, which would resample a transform that has an // the matrix above, which would resample a transform that has an
// exact answer. Applied to `p`, so the aspect scaling has to be // exact answer. Applied to `p`, so the aspect scaling has to be
// undone and reapplied across the swap. // 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);", 1 => " p = vec2<f32>(p.y * aspect.x, -p.x / aspect.x);",
2 => " p = -p;", 2 => " p = -p;",
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / aspect.x);", _ => " 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. // {}° clockwise — an exact permutation, so nothing is resampled.
{permutation} {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"); s.push_str(" p.x = -p.x;\n");
} }
if self.flip_v { if flip_v {
s.push_str(" p.y = -p.y;\n"); 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 /// any zoom and cleared by none, so a wheel notch is still a uniform
/// upload rather than a shader build. /// upload rather than a shader build.
pub fn structure_key(&self) -> u64 { 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.crop.is_full())
| u64::from(self.angle != 0.0) << 1 | u64::from(self.angle != 0.0) << 1
| u64::from(self.flip_h) << 2 | u64::from(flip_h) << 2
| u64::from(self.flip_v) << 3 | u64::from(flip_v) << 3
| u64::from(self.quarter_turns) << 4 | u64::from(turns) << 4
| u64::from(self.is_active()) << 6 | u64::from(self.is_active()) << 6
} }
} }
@@ -639,6 +726,159 @@ pub const FRAMING_UNIFORM_FIELDS: usize = 8;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn a_fresh_framing_is_neutral() { fn a_fresh_framing_is_neutral() {
+9
View File
@@ -268,6 +268,15 @@ impl EditGraph {
self.framing.rotate_quarters(turns); 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. /// Whether any operation, or the framing, currently changes the image.
/// ///
/// Asks the framing whether it *edits*, not whether it is active: zoom /// Asks the framing whether it *edits*, not whether it is active: zoom
+42
View File
@@ -683,6 +683,48 @@ mod tests {
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0)); 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] #[test]
fn applying_a_version_replaces_rather_than_overlays() { fn applying_a_version_replaces_rather_than_overlays() {
// Loading an edit onto a graph that already holds one must not leave // Loading an edit onto a graph that already holds one must not leave
+223
View File
@@ -269,6 +269,114 @@ pub enum Availability {
Offline, 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 /// An opaque change-validator for a remote entry (an ETag, or an mtime where
/// no ETag exists). /// no ETag exists).
/// ///
@@ -311,6 +419,121 @@ pub enum SourceError {
mod tests { mod tests {
use super::*; 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] #[test]
fn display_name_handles_paths_and_saf_ids() { fn display_name_handles_paths_and_saf_ids() {
let local = SourceRef::Local { let local = SourceRef::Local {
+22
View File
@@ -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 Inference is **local only** — no cloud, no telemetry (NFR-SEC-4). The stage is optional at runtime
and its absence degrades gracefully. 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 **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 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 — shall reach the display without a CPU round-trip.** *(This is a hard architectural constraint —
+22 -21
View File
@@ -10,16 +10,16 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value | | Metric | Value |
|---|---| |---|---|
| Source files scanned | 93 | | Source files scanned | 93 |
| TRACES tags found | 137 | | TRACES tags found | 141 |
| Requirements defined | 150 | | Requirements defined | 151 |
| Requirements covered | 71 | | Requirements covered | 72 |
| **Coverage** | **47.3%** (71/150) | | **Coverage** | **47.7%** (72/151) |
### By type ### By type
| Type | Covered | Defined | | Type | Covered | Defined |
|---|---|---| |---|---|---|
| FR | 55 | 96 | | FR | 56 | 97 |
| NFR | 14 | 48 | | NFR | 14 | 48 |
| R | 2 | 6 | | 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-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-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-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-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-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-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-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-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-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-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-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:96`](../core/dr-decode/src/preview.rs#L96) | | 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:123`](../core/dr-decode/src/preview.rs#L123) | | 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-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-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-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-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-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-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-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-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) | | 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-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-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-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-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-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-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-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-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-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-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) | | 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-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-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-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-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:359`](../core/dr-decode/src/lib.rs#L359), [`core/dr-decode/src/lib.rs:85`](../core/dr-decode/src/lib.rs#L85) | | 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-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-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:1397`](../ui/dr-ui/src/lib.rs#L1397), [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) | | 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-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-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-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-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-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-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-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-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-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) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) |
@@ -107,7 +108,7 @@ _None._
## Not yet tagged ## 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.
<details><summary>Show untagged requirements</summary> <details><summary>Show untagged requirements</summary>
+30 -8
View File
@@ -28,10 +28,19 @@ pub struct DevelopSession {
impl DevelopSession { impl DevelopSession {
/// Demosaic an image and prepare its edit graph. /// Demosaic an image and prepare its edit graph.
pub fn open(ctx: &GpuContext, raw: &RawImage) -> Result<Self, String> { ///
/// `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<Self, String> {
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?; let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
let demosaiced = demosaicer.run(raw).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. /// Prepare an edit graph over an already-processed RGB image.
@@ -49,15 +58,22 @@ impl DevelopSession {
rgba: &[u8], rgba: &[u8],
width: u32, width: u32,
height: u32, height: u32,
orientation: dr_types::Orientation,
) -> Result<Self, String> { ) -> Result<Self, String> {
let source = let source =
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?; 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 { Self {
graph: EditGraph::default_chain(), graph,
demosaiced, demosaiced,
adjust: AdjustPass::new(ctx), adjust: AdjustPass::new(ctx),
} }
@@ -751,7 +767,9 @@ mod tests {
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]); 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"); let fitted = session.render(64, 64).expect("fitted render");
session.zoom_about(4.0, 0.5, 0.5); 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 // Bigger than the viewport it is shown in: `fit` scales it down, so
// every screen pixel still has several source pixels behind it. // every screen pixel still has several source pixels behind it.
let big = vec![128u8; (800 * 800 * 4) as usize]; 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!( assert!(
!session.magnifies_source(200, 200), !session.magnifies_source(200, 200),
"a downscaled image is not magnified" "a downscaled image is not magnified"
@@ -808,7 +828,9 @@ mod tests {
// Smaller than the viewport: `fit` refuses to upscale, so the render is // Smaller than the viewport: `fit` refuses to upscale, so the render is
// 1:1 and unzoomed is exactly the boundary — not past it. // 1:1 and unzoomed is exactly the boundary — not past it.
let small = vec![128u8; (100 * 100 * 4) as usize]; 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!( assert!(
!session.magnifies_source(800, 800), !session.magnifies_source(800, 800),
"1:1 is the boundary, not past it — filtering must not flip on a \ "1:1 is the boundary, not past it — filtering must not flip on a \
+10 -2
View File
@@ -120,6 +120,10 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
// mode work on the JPEGs the library already indexes. // mode work on the JPEGs the library already indexes.
let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg); let is_jpeg = dr_decode::probe(bytes) == Some(dr_types::Format::Jpeg);
// How the file stored its pixels. A file that says nothing is taken as
// upright — see `Orientation::from_exif`.
let orientation = meta.orientation.unwrap_or_default();
if let Some(ctx) = ctx { if let Some(ctx) = ctx {
let opened = if is_jpeg { let opened = if is_jpeg {
dr_decode::decode_jpeg(bytes) dr_decode::decode_jpeg(bytes)
@@ -139,14 +143,14 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
); );
p.downscale_to(limit); p.downscale_to(limit);
} }
DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height) DevelopSession::open_rgb(ctx, &p.rgba, p.width, p.height, orientation)
}) })
} else { } else {
// A failure here is expected for bodies rawler does not know, and // A failure here is expected for bodies rawler does not know, and
// must not stop the image displaying (FR-RAW-4). // must not stop the image displaying (FR-RAW-4).
dr_decode::decode(bytes) dr_decode::decode(bytes)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
.and_then(|raw| DevelopSession::open(ctx, &raw)) .and_then(|raw| DevelopSession::open(ctx, &raw, orientation))
}; };
match opened { match opened {
@@ -169,6 +173,10 @@ fn load_bytes(ctx: Option<&dr_gpu::GpuContext>, bytes: &[u8]) -> Result<Loaded,
let mut preview = let mut preview =
dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?; dr_decode::extract_preview(bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
preview.downscale_to(MAX_DISPLAY_DIM); preview.downscale_to(MAX_DISPLAY_DIM);
// No graph here to carry the baseline, so the pixels are turned instead.
// Cheaper than it sounds after the downscale, and this path is the one a
// phone without a working GPU lands on — where sideways is most likely.
preview.apply_orientation(orientation);
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice( let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
&preview.rgba, &preview.rgba,
+15
View File
@@ -1332,6 +1332,13 @@ async fn fetch_one(
collect_metadata(&header, req, found_metadata); 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. // A plain JPEG is its own preview; anything else needs locating.
let bytes = if header.starts_with(&[0xFF, 0xD8, 0xFF]) { let bytes = if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
match backend.get(&id, None).await { match backend.get(&id, None).await {
@@ -1368,6 +1375,14 @@ async fn fetch_one(
Err(e) => return fail(e.to_string()), Err(e) => return fail(e.to_string()),
}; };
preview.downscale_to(req.thumb_size.edge()); 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. // 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, // A store failure is logged and dropped: the pixels are already in hand,