From 2147eaa6a5588dcc16d88489bcf52085e5c4d448 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 15:50:49 +0200 Subject: [PATCH 01/27] Put a keyword on a photograph, not only search for one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog has been able to *find* by keyword since v1 — query.rs joins the keywords table, matches it exactly, and substring-matches it for free text — and nothing anywhere could ever put a word there. A user could filter to a keyword they had no way to apply. This is the missing half: create, rename, delete, list, assign, unassign, and the two reads a panel needs. Bulk-only for assignment, because keywording a selection is the common case rather than the exception — the photographer picks out the frames with the puffin in them and applies "puffin" once, in one transaction. Schema v6 adds `keyword_terms`, and deliberately does *not* touch the v1 join. The assignment keeps the word as text because the catalog is a rebuildable index and the durable copies of that fact — the sidecar, XMP dc:subject — both carry a string; a foreign key would mean a catalog rebuilt from sidecars had to invent identity rows before it could record anything, and would break the query path that already works. So the text is the fact, and the new table is only the identity a rename and a deletion can be keyed on. `keyword_terms.name` carries no unique index, which looks like an oversight and is not: two devices that each type "Iceland" are both right until they meet, and a constraint would abort the merge at that moment. Uniqueness is converged upon instead — create resolves an existing name, fuse_duplicates collapses a cross-device pair onto the smaller uuid. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-catalog/src/collections.rs | 2 +- core/dr-catalog/src/error.rs | 9 + core/dr-catalog/src/keywords.rs | 1220 ++++++++++++++++++++++++++++ core/dr-catalog/src/lib.rs | 5 +- core/dr-catalog/src/schema.rs | 188 ++++- 5 files changed, 1417 insertions(+), 7 deletions(-) create mode 100644 core/dr-catalog/src/keywords.rs diff --git a/core/dr-catalog/src/collections.rs b/core/dr-catalog/src/collections.rs index 30d642a..0f4a5e0 100644 --- a/core/dr-catalog/src/collections.rs +++ b/core/dr-catalog/src/collections.rs @@ -682,7 +682,7 @@ fn require_exists(conn: &Connection, id: CollectionId) -> Result<(), CatalogErro /// same reasoning as the connector's date parsing. Version 4 layout, seeded /// from the OS via `getrandom` through `rusqlite`'s existing dependency-free /// path — see below. -fn new_uuid() -> String { +pub(crate) fn new_uuid() -> String { let b = random_bytes(); // Version 4, variant 1, per RFC 4122 §4.4. let v6 = (b[6] & 0x0F) | 0x40; diff --git a/core/dr-catalog/src/error.rs b/core/dr-catalog/src/error.rs index b6d6f08..0a8f0da 100644 --- a/core/dr-catalog/src/error.rs +++ b/core/dr-catalog/src/error.rs @@ -42,6 +42,15 @@ pub enum CatalogError { #[error("no such collection: {0}")] NoSuchCollection(u64), + /// A keyword the caller named is gone — deleted, or fused into another by a + /// merge while its id sat in a UI model. + /// + /// Its own variant rather than a silent no-op because the two are different + /// answers to the user: a rename that quietly did nothing looks exactly like + /// a rename that did not take. + #[error("no such keyword: {0}")] + NoSuchKeyword(u64), + /// Images were dropped onto a smart collection. /// /// A smart collection's membership *is* its selector, so member rows would diff --git a/core/dr-catalog/src/keywords.rs b/core/dr-catalog/src/keywords.rs new file mode 100644 index 0000000..c3676ba --- /dev/null +++ b/core/dr-catalog/src/keywords.rs @@ -0,0 +1,1220 @@ +//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CAT-13 | NFR-R5 +//! Keywords: the vocabulary, the assignments, and the edits the UI performs. +//! +//! The read half of this shipped with the catalog and the write half did not. +//! [`crate::query`] has compiled [`dr_types::Selector::Keyword`] against the +//! `keywords` table since v1, and [`dr_types::Selector::Text`] substring-matches +//! it, so the library could always be searched by keyword — but nothing could +//! ever *put* one there. A user could filter to a word they had no way to +//! apply. This module is the missing half. +//! +//! # Two tables, and which one is the fact +//! +//! **`keywords`** is the assignment: `(version_id, keyword)`, where the keyword +//! is the *word itself* as text. This is the durable fact. It is what +//! [`crate::query`] matches, what a sidecar carries, and what XMP `dc:subject` +//! interoperates on (FR-CAT-13) — every one of which speaks in strings. +//! +//! **`keyword_terms`** is the identity: uuid, revision, tombstone. It exists so +//! that a *rename* and a *deletion* have something a cross-device merge can key +//! on, and so that a keyword can exist in the vocabulary before any photograph +//! carries it. It is not referenced by the assignment rows; see the V6 +//! migration in [`crate::schema`] for why pointing at it would be a mistake. +//! +//! A rename therefore writes both: the term rows (so the edit merges) and every +//! assignment carrying the old text (so the search, the sidecar and the XMP all +//! agree). Those writes are one transaction, because a catalog holding half a +//! rename would show the keyword under one name and find it under the other. +//! [`rename`] explains why the old identity is retired rather than relabelled. +//! +//! # Which version an assignment lands on +//! +//! The schema hangs keywords off `versions`, not `images`, and that is right: +//! FR-NC-8 makes the sidecar a keyed set of versions, so everything durable +//! about a photograph is stored per version. But a *write* from the UI has to +//! land somewhere definite, and it lands on the **default** version — the same +//! choice [`crate::rating`] makes, for the same reason. +//! +//! Reading is deliberately asymmetric: [`crate::query`] matches a keyword on +//! *any* version of an image (`EXISTS ... WHERE kv.image_id = images.id`), and +//! [`for_images`] does the same. A keyword names what is in the frame, so a +//! word applied to a virtual copy must still find the photograph — but there +//! must be exactly one place a UI write goes, or two copies of one frame come +//! to disagree about their own subject. +//! +//! # Uniqueness is converged upon, not constrained +//! +//! `keyword_terms.name` carries no unique index. Two devices that each type +//! "Iceland" both create a term, with different uuids, and neither is wrong +//! until they meet. A constraint would abort the merge transaction at that +//! moment — which is the ordinary case, not a corner one. +//! +//! Instead: [`create`] resolves an existing name rather than adding a second +//! row, and [`fuse_duplicates`] collapses a cross-device pair onto the +//! lexicographically smaller uuid. That rule is deterministic and symmetric, so +//! both devices reach the same answer without a round trip. +//! +//! # Keywords are user text, and they only ever bind +//! +//! Every keyword here reaches SQLite as a bound parameter. `crate::query` has +//! the same rule and a test that asserts it; this module is the write side of +//! the same guarantee, and the reason it matters more here is that this is +//! where the text arrives from the user in the first place. + +use rusqlite::{Connection, OptionalExtension}; + +use dr_types::ImageId; + +use crate::error::CatalogError; + +/// A keyword's local row id in `keyword_terms`. +/// +/// Local to this catalog and meaningless on another device, exactly as +/// [`dr_types::CollectionId`] is — [`Keyword::uuid`] is what a merge keys on. +/// +/// Defined here rather than in `dr-types` because nothing outside the catalog +/// and the panel that draws it has any use for it: an id that names a row in a +/// rebuildable index is not a term of the shared vocabulary the way an +/// `ImageId` is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct KeywordId(pub u64); + +/// One keyword, as the vocabulary list draws it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Keyword { + pub id: KeywordId, + /// Device-independent identity. The integer `id` is local and collides + /// across devices; this is what a merge keys on. + pub uuid: String, + pub name: String, + /// How many images in the whole library carry it. Shown beside the word so + /// the user can tell a keyword they use from one they typed once by + /// mistake. + pub image_count: usize, +} + +/// A keyword's standing across a *selection*, for the panel. +/// +/// The three-way distinction is the whole reason this type exists: applying a +/// keyword to forty photographs where thirty already carry it must not look +/// the same as applying it to forty that do not, and removing one that only +/// some of them carry must not silently claim to have removed it from all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Coverage { + /// No image in the selection carries it. + None, + /// Some do and some do not — Lightroom's dash rather than a tick. + Some, + /// Every image in the selection carries it. + All, +} + +/// A keyword plus how much of the current selection it covers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionKeyword { + pub keyword: Keyword, + pub coverage: Coverage, + /// How many of the *selected* images carry it. + pub selected_count: usize, +} + +/// Longest keyword accepted. +/// +/// Not a storage limit — SQLite would take a megabyte — but a paste guard. The +/// field that feeds this is one line in a sheet, and a keyword the width of a +/// paragraph is a mis-paste rather than an intention. Truncating rather than +/// refusing, so the paste is recoverable by editing rather than lost. +pub const MAX_KEYWORD_LEN: usize = 128; + +/// Create a keyword, or return the one that already carries this name. +/// +/// **Resolving rather than refusing** is what separates this from +/// [`crate::collections::create`], which happily makes a second "Iceland". A +/// keyword *is* its text — that is what the assignment rows store and what XMP +/// carries — so two term rows with one name are two identities for one thing, +/// and the second would be a duplicate in the vocabulary list that no amount +/// of user care could distinguish from the first. +/// +/// The UUID is generated here rather than taken from the caller: it is the +/// merge identity, and a caller that reuses one fuses two keywords at the next +/// sync. +pub fn create(conn: &Connection, name: &str) -> Result { + let name = normalise(name)?; + + // A tombstoned row is deliberately *not* resurrected here: it carries a + // revision that says "deleted", and reusing it would make the new keyword + // inherit an argument it was never part of. A fresh uuid starts the + // conversation again, which is what the user asked for by typing the word. + if let Some(id) = live_id_for_name(conn, &name)? { + return Ok(id); + } + + let now = now_secs(); + conn.execute( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified) + VALUES (?1, ?2, ?3, 1, ?3)", + rusqlite::params![new_uuid(), name, now], + )?; + Ok(KeywordId(conn.last_insert_rowid() as u64)) +} + +/// Rename a keyword, everywhere it appears. +/// +/// Returns the identity the word now has, which is **not** the one passed in. +/// +/// # A rename retires one word and raises another +/// +/// The obvious implementation — change `name` on the term row and keep its +/// uuid — is wrong here, and the reason is that assignments store *text*. Take +/// two keywords, "Icland" and "Iceland", and rename the first onto the second. +/// Something has to give, because two live rows may not both be "Iceland", and +/// every way of choosing between them by uuid or by revision loses the rename +/// on the device that chose the other way. The rename then silently fails to +/// propagate, which is the worst of the outcomes available. +/// +/// So a rename is modelled as what it actually is to a body of *text*: the old +/// word stops existing and the new one exists. The old term row is tombstoned +/// **keeping its old name**, the new word gets (or already has) a live row, and +/// every assignment is rewritten between them. That composes with the merge +/// rules already in place — the tombstone travels as a deletion and takes the +/// old word's assignments with it on every device, and the new word travels as +/// an ordinary keyword — rather than needing a rule of its own. +/// +/// One transaction over both tables. A catalog holding half a rename would show +/// a photograph under the new word and fail to find it under either. +/// +/// Renaming onto a name another keyword already holds therefore **fuses the +/// two**, which is almost always what was meant: the user is correcting +/// "Icland" to "Iceland" and there is already an "Iceland". Refusing would +/// leave them to do it by hand, image by image, with no bulk gesture to do it +/// with. +pub fn rename(conn: &Connection, id: KeywordId, name: &str) -> Result { + let name = normalise(name)?; + let old = name_of(conn, id)?; + if old == name { + // Not an error, and not an edit either: bumping the revision for a + // no-op rename would let an idle device win a merge against one that + // did real work. + return Ok(id); + } + + let tx = conn.unchecked_transaction()?; + + // Assignments first. If this fails, the term rows are untouched and the + // catalog is merely unchanged rather than internally inconsistent. + rewrite_assignments(&tx, &old, &name)?; + retire(&tx, id)?; + let now = create(&tx, &name)?; + + tx.commit()?; + Ok(now) +} + +/// Delete a keyword, leaving a tombstone. +/// +/// The term row survives with `deleted = 1` because a merge against a device +/// that still holds the keyword would otherwise resurrect it — the same rule +/// [`crate::collections::delete`] follows. +/// +/// The assignment rows go outright. They carry no independent identity: the +/// tombstone is what merges, and a photograph keyworded with a word that no +/// longer exists would be searchable by a term absent from every list. +/// +/// Returns how many assignments went. +pub fn delete(conn: &Connection, id: KeywordId) -> Result { + let name = name_of(conn, id)?; + + let tx = conn.unchecked_transaction()?; + let removed = tx.execute("DELETE FROM keywords WHERE keyword = ?1", [&name])?; + retire(&tx, id)?; + tx.commit()?; + Ok(removed) +} + +/// The whole vocabulary, most-used first and then alphabetical. +/// +/// Used-first because the list is a target for a thumb: the words a +/// photographer reaches for are the ones they already use, and burying them +/// under a one-off typo sorted to the top is what makes a keyword list stop +/// being used. Alphabetical *within* a count so the order is stable across +/// launches and across devices — row-id order would differ per device, which +/// is disorienting on the same library seen from two machines. +/// +/// One grouped aggregate rather than a count per row: the sheet redraws on +/// every assignment, and a query per keyword would be one statement per word +/// in the library. +pub fn list(conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare( + // DISTINCT image, not row: a word on two versions of one frame is one + // photograph, and reporting two is the kind of small lie that makes a + // user stop trusting the counts. + "SELECT t.id, t.uuid, t.name, + (SELECT count(DISTINCT v.image_id) + FROM keywords k JOIN versions v ON v.id = k.version_id + WHERE k.keyword = t.name) + FROM keyword_terms t + WHERE t.deleted = 0 + ORDER BY 4 DESC, t.name COLLATE NOCASE, t.id", + )?; + let rows = stmt + .query_map([], |r| { + Ok(Keyword { + id: KeywordId(r.get::<_, i64>(0)? as u64), + uuid: r.get(1)?, + name: r.get(2)?, + image_count: r.get::<_, i64>(3)? as usize, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + +/// Assign a keyword to images, creating the keyword if it is new. +/// +/// The bulk form is the *only* form, because keywording a selection is the +/// common case rather than the exception: a photographer picks out the frames +/// with the puffin in them and applies "puffin" once. One transaction, so a +/// crash partway through cannot leave half a selection keyworded — the same +/// reasoning as [`crate::rating::set_rating_many`]. +/// +/// Additive and idempotent. An image that already carries the word is left +/// alone rather than rewritten, because re-applying a keyword to a selection +/// that overlaps what is already tagged is a normal thing to do. +/// +/// Returns how many images genuinely gained it, which is what the UI reports — +/// "added to 3 of 12" is the honest message when nine already had it. +pub fn assign(conn: &Connection, images: &[ImageId], name: &str) -> Result { + let name = normalise(name)?; + let tx = conn.unchecked_transaction()?; + + // The term row first, so the word appears in the vocabulary even when the + // selection turns out to be empty — typing a keyword into the field and + // pressing return is a legitimate way to build a vocabulary ahead of the + // photographs it will be used on. + create(&tx, &name)?; + + let mut added = 0; + { + let mut stmt = tx.prepare( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, ?2) + ON CONFLICT(version_id, keyword) DO NOTHING", + )?; + for image in images { + // Creates the version if the image has none — a library scanned + // before versions existed, or a scan interrupted between the image + // insert and the commit. Failing the keyword because of either + // would be the wrong answer: the user typed a word and expects it + // to stick. + let version = crate::rating::default_version_id(&tx, *image)?; + added += stmt.execute(rusqlite::params![version, name])?; + } + } + + tx.commit()?; + Ok(added) +} + +/// Take a keyword off images. +/// +/// Removes the assignment only. The keyword itself survives in the vocabulary +/// and on every other photograph that carries it — that asymmetry is the point +/// of a join table, and it is why this is "remove from these images" and never +/// "delete the keyword". [`delete`] is the other gesture, and it says so. +/// +/// Removes it from **every** version of each image, not only the default. A +/// user who unticks a word is saying the photograph is not of that thing, and +/// leaving it on a virtual copy would keep the frame in the search results +/// with nothing in the panel to explain why. +pub fn unassign(conn: &Connection, images: &[ImageId], name: &str) -> Result { + let name = normalise(name)?; + if images.is_empty() { + return Ok(0); + } + + let tx = conn.unchecked_transaction()?; + let mut removed = 0; + { + let mut stmt = tx.prepare( + "DELETE FROM keywords + WHERE keyword = ?2 + AND version_id IN (SELECT id FROM versions WHERE image_id = ?1)", + )?; + for image in images { + if stmt.execute(rusqlite::params![image.0 as i64, name])? > 0 { + removed += 1; + } + } + } + tx.commit()?; + Ok(removed) +} + +/// Every keyword on one image, alphabetically. +/// +/// Across all its versions, and de-duplicated: the panel shows what the +/// photograph is of, and a word on two virtual copies is still one subject. +pub fn for_image(conn: &Connection, image: ImageId) -> Result, CatalogError> { + let mut stmt = conn.prepare( + "SELECT DISTINCT k.keyword + FROM keywords k JOIN versions v ON v.id = k.version_id + WHERE v.image_id = ?1 + ORDER BY k.keyword COLLATE NOCASE", + )?; + let rows = stmt + .query_map([image.0 as i64], |r| r.get(0))? + .collect::, _>>()?; + Ok(rows) +} + +/// The vocabulary, annotated with how much of `images` each keyword covers. +/// +/// This is what the keywording panel draws: one list, in which a word the whole +/// selection already carries, a word only some of it carries, and a word none +/// of it carries are three visibly different things. +/// +/// Two statements regardless of the selection size — the vocabulary, and one +/// grouped count over the selection. A count per keyword would be one query +/// per word on every redraw, which is the cost `crate::rating::judgements` +/// exists to avoid for stars. +/// +/// A word that is on an image but has somehow lost its term row still appears, +/// synthesised with no identity. That happens to a catalog rebuilt from +/// sidecars between the rebuild and the next [`adopt_orphan_terms`], and a +/// panel that omitted the keywords the photograph visibly has would read as the +/// keywords having been lost. +pub fn for_images( + conn: &Connection, + images: &[ImageId], +) -> Result, CatalogError> { + let vocabulary = list(conn)?; + if images.is_empty() { + return Ok(vocabulary + .into_iter() + .map(|keyword| SelectionKeyword { + keyword, + coverage: Coverage::None, + selected_count: 0, + }) + .collect()); + } + + // Placeholders are generated from the *count* of ids, never from any text + // that came from outside — the same rule `rating::judgements` follows, and + // the reason a keyword can never reach SQL as anything but a parameter. + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT k.keyword, count(DISTINCT v.image_id) + FROM keywords k JOIN versions v ON v.id = k.version_id + WHERE v.image_id IN ({placeholders}) + GROUP BY k.keyword" + ); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let mut counts: std::collections::HashMap = std::collections::HashMap::new(); + { + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)) + })?; + for (word, n) in rows.flatten() { + counts.insert(word, n as usize); + } + } + + let mut out: Vec = Vec::with_capacity(vocabulary.len()); + let mut seen = std::collections::HashSet::new(); + for keyword in vocabulary { + let n = counts.get(&keyword.name).copied().unwrap_or(0); + seen.insert(keyword.name.clone()); + out.push(SelectionKeyword { + coverage: coverage_of(n, images.len()), + selected_count: n, + keyword, + }); + } + + // Words the selection carries that the vocabulary has no row for. Sorted + // and appended rather than interleaved, so the list the user has been + // looking at does not reshuffle around them. + let mut orphans: Vec<(String, usize)> = counts + .into_iter() + .filter(|(word, _)| !seen.contains(word)) + .collect(); + orphans.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); + for (name, n) in orphans { + out.push(SelectionKeyword { + keyword: Keyword { + // Zero, because there is no row. The panel treats it as a word + // it can assign and unassign but not rename — which is exactly + // true until the next backfill gives it an identity. + id: KeywordId(0), + uuid: String::new(), + image_count: n, + name, + }, + coverage: coverage_of(n, images.len()), + selected_count: n, + }); + } + + Ok(out) +} + +fn coverage_of(carrying: usize, selected: usize) -> Coverage { + if carrying == 0 { + Coverage::None + } else if carrying >= selected { + Coverage::All + } else { + Coverage::Some + } +} + +/// Give a term row to every word some image carries without one. +/// +/// Runs from [`crate::schema::backfill`] on every open. Cheap on the common +/// path: one anti-joined scan of an indexed column that inserts nothing once +/// the vocabulary is complete. +/// +/// Returns how many terms were adopted, so an import or a rebuild can log it +/// rather than silently writing thousands of rows. +pub fn adopt_orphan_terms(conn: &Connection) -> Result { + let orphans: Vec = { + let mut stmt = conn.prepare( + // A tombstone counts as "has a term row". A word the user deleted + // whose assignment somehow outlived the deletion must not be + // quietly readmitted to the vocabulary; it stays visible as an + // orphan in [`for_images`] instead, which is a state someone can + // see and act on rather than one that silently undoes a deletion. + "SELECT DISTINCT k.keyword FROM keywords k + WHERE NOT EXISTS (SELECT 1 FROM keyword_terms t + WHERE t.name = k.keyword)", + )?; + let found = stmt + .query_map([], |r| r.get(0))? + .collect::, _>>()?; + found + }; + if orphans.is_empty() { + return Ok(0); + } + + // One transaction for the batch. An import from Lightroom can bring in + // hundreds of keywords, and per-statement commits would be hundreds of + // fsyncs for what is conceptually one adoption. + let tx = conn.unchecked_transaction()?; + let now = now_secs(); + { + let mut stmt = tx.prepare( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified) + VALUES (?1, ?2, ?3, 1, ?3)", + )?; + for name in &orphans { + stmt.execute(rusqlite::params![new_uuid(), name, now])?; + } + } + tx.commit()?; + Ok(orphans.len()) +} + +/// Collapse term rows that describe the same word onto one identity. +/// +/// The keyword *is* its text, so two live rows named "Iceland" are two +/// identities for one thing. That happens for one ordinary reason: two devices +/// each typed the word before they had ever synced, and each minted a uuid. +/// +/// The survivor is the **lexicographically smallest uuid**, and the losers are +/// deleted outright rather than tombstoned. Both halves of that matter: +/// +/// - *Smallest uuid* is a rule both devices apply to the same pair and reach +/// the same answer from, with no round trip and no ordering dependency. Any +/// rule that consulted a revision or a timestamp would let two devices pick +/// different survivors and never converge. +/// - *Deleted, not tombstoned*, because the word itself has not been deleted — +/// the redundant row is being retired, and a tombstone would propagate as +/// "the user removed this keyword" and take the assignments with it. +/// +/// Assignment rows need no repair: they store the text, which is the same on +/// both sides, which is precisely why fusing is possible at all. +/// +/// Returns how many rows were retired. +pub fn fuse_duplicates(conn: &Connection) -> Result { + let n = conn.execute( + "DELETE FROM keyword_terms + WHERE deleted = 0 + AND uuid > (SELECT min(o.uuid) FROM keyword_terms o + WHERE o.deleted = 0 AND o.name = keyword_terms.name)", + [], + )?; + Ok(n) +} + +/// Point every assignment at a new spelling of the same word. +/// +/// `INSERT OR IGNORE` then `DELETE` rather than a bare `UPDATE`, because a +/// version may already carry the destination word — renaming "Icland" to +/// "Iceland" on a frame that has both — and the primary key would reject the +/// update for that row and abort the rename for every other. +fn rewrite_assignments(conn: &Connection, from: &str, to: &str) -> Result<(), CatalogError> { + conn.execute( + "INSERT OR IGNORE INTO keywords(version_id, keyword) + SELECT version_id, ?2 FROM keywords WHERE keyword = ?1", + rusqlite::params![from, to], + )?; + conn.execute("DELETE FROM keywords WHERE keyword = ?1", [from])?; + Ok(()) +} + +/// The stored form of a keyword the user typed. +/// +/// Trimmed, inner whitespace collapsed, and length-capped. Trimming matters +/// more than it looks: `keywords.keyword` is compared with `=` by +/// [`crate::query`], so " puffin" and "puffin" would be two keywords that look +/// identical in every list and never match each other's searches. +/// +/// Case is deliberately **preserved**. "Iceland" is a place and "iceland" is a +/// typo of it, and a photographer who capitalises their proper nouns should +/// find them capitalised. The lists sort `COLLATE NOCASE` so the two still land +/// beside each other where both exist. +/// +/// Public because a caller that is about to *tell the user* what it did needs +/// the word as it will be stored, not as it was typed. Reporting `Added " +/// puffin "` for something the list then shows as `puffin` is a small +/// inconsistency, but it is the kind that makes a user wonder whether the +/// leading space mattered — and the only way to answer that from the outside +/// is to duplicate this rule, which is how the two come to disagree. +pub fn normalise(name: &str) -> Result { + let collapsed = name.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return Err(CatalogError::BadName( + "A keyword needs a word in it.".into(), + )); + } + // Truncated on a *character* boundary: `String::truncate` panics mid-code + // point, and a keyword is as likely to be "Þingvellir" as "puffin". + Ok(collapsed.chars().take(MAX_KEYWORD_LEN).collect()) +} + +/// The live term row holding this exact name, if there is one. +fn live_id_for_name(conn: &Connection, name: &str) -> Result, CatalogError> { + let id: Option = conn + .query_row( + "SELECT id FROM keyword_terms WHERE name = ?1 AND deleted = 0 ORDER BY uuid LIMIT 1", + [name], + |r| r.get(0), + ) + .optional()?; + Ok(id.map(|v| KeywordId(v as u64))) +} + +fn name_of(conn: &Connection, id: KeywordId) -> Result { + conn.query_row( + "SELECT name FROM keyword_terms WHERE id = ?1 AND deleted = 0", + [id.0 as i64], + |r| r.get(0), + ) + .optional()? + .ok_or(CatalogError::NoSuchKeyword(id.0)) +} + +/// Tombstone a term row, bumping its revision. +/// +/// **The name is deliberately left as it was.** A tombstone is read by the +/// merge as "the user removed *this word*", and the other device acts on it by +/// deleting the assignments carrying that text — so a tombstone renamed to the +/// word it was merged into would delete the assignments of the surviving +/// keyword instead of the retired one. See [`rename`]. +/// +/// The revision bump is not optional. [`crate::merge`] compares revisions, so a +/// deletion that updated `modified` alone is invisible to it — the other device +/// keeps its live row and the keyword comes back at the next sync. +fn retire(conn: &Connection, id: KeywordId) -> Result<(), CatalogError> { + conn.execute( + "UPDATE keyword_terms + SET deleted = 1, revision = revision + 1, modified = ?2 + WHERE id = ?1", + rusqlite::params![id.0 as i64, now_secs()], + )?; + Ok(()) +} + +/// A random UUID, formatted as the canonical 8-4-4-4-12. +/// +/// Shares [`crate::collections`]'s implementation rather than repeating it: a +/// second generator is a second thing to get wrong, and the merge identity is +/// the one place a weak one is unrecoverable. +fn new_uuid() -> String { + crate::collections::new_uuid() +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Catalog; + use dr_types::Selector; + + /// A catalog with six images, each with a default version. + fn seeded() -> Catalog { + let cat = Catalog::in_memory().unwrap(); + let c = cat.connection(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + for i in 1..=6i64 { + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) + VALUES (?1, 1, ?2, 0)", + rusqlite::params![i, format!("IMG_{i}.CR3")], + ) + .unwrap(); + } + crate::rating::ensure_default_versions(c).unwrap(); + cat + } + + fn img(n: u64) -> ImageId { + ImageId(n) + } + + fn names(cat: &Catalog) -> Vec { + list(cat.connection()) + .unwrap() + .into_iter() + .map(|k| k.name) + .collect() + } + + #[test] + fn a_keyword_can_be_applied_and_then_found() { + // The whole point: the search path existed and nothing could feed it. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + + let q = crate::Query { + filter: Selector::Keyword("puffin".into()), + ..Default::default() + }; + assert_eq!(cat.count(&q, 0).unwrap(), 2); + } + + #[test] + fn assigning_to_a_selection_is_one_gesture() { + let cat = seeded(); + let all: Vec = (1..=6).map(img).collect(); + assert_eq!(assign(cat.connection(), &all, "iceland").unwrap(), 6); + } + + #[test] + fn reapplying_to_an_overlapping_selection_reports_only_the_new_ones() { + // "Added to 1 of 3" is the honest message, and the UI shows it. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + let added = assign(cat.connection(), &[img(1), img(2), img(3)], "puffin").unwrap(); + assert_eq!(added, 1); + } + + #[test] + fn assignment_is_idempotent_and_stores_one_row() { + let cat = seeded(); + assign(cat.connection(), &[img(1)], "puffin").unwrap(); + assign(cat.connection(), &[img(1)], "puffin").unwrap(); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["puffin"]); + } + + #[test] + fn unassigning_leaves_the_keyword_on_everything_else() { + // The join-table asymmetry: removing a word from one photograph is not + // deleting the word. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + assert_eq!(unassign(cat.connection(), &[img(1)], "puffin").unwrap(), 1); + + assert!(for_image(cat.connection(), img(1)).unwrap().is_empty()); + assert_eq!(for_image(cat.connection(), img(2)).unwrap(), ["puffin"]); + assert_eq!(names(&cat), ["puffin"], "the vocabulary still has it"); + } + + #[test] + fn a_word_on_a_virtual_copy_is_removed_with_the_frame() { + // Unticking says "this photograph is not of that", and a word left on + // a virtual copy would keep the frame in the results with nothing in + // the panel to explain it. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO versions(image_id, uuid, name, is_default) VALUES (1, 'copy', 'Crop', 0)", + [], + ) + .unwrap(); + let copy: i64 = c.last_insert_rowid(); + assign(c, &[img(1)], "puffin").unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'puffin')", + [copy], + ) + .unwrap(); + + unassign(c, &[img(1)], "puffin").unwrap(); + let n: i64 = c + .query_row("SELECT count(*) FROM keywords", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 0); + } + + #[test] + fn a_keyword_on_a_virtual_copy_still_finds_the_photograph() { + // The read side is deliberately asymmetric with the write side: a word + // names what is in the frame, whichever copy carries it. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO versions(image_id, uuid, name, is_default) VALUES (3, 'copy', 'Crop', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'gannet')", + [c.last_insert_rowid()], + ) + .unwrap(); + + let q = crate::Query { + filter: Selector::Keyword("gannet".into()), + ..Default::default() + }; + assert_eq!(cat.count(&q, 0).unwrap(), 1); + } + + #[test] + fn creating_the_same_word_twice_is_one_keyword() { + // A keyword is its text. Two rows with one name would be a duplicate + // in the list that no user could tell apart. + let cat = seeded(); + let a = create(cat.connection(), "iceland").unwrap(); + let b = create(cat.connection(), "iceland").unwrap(); + assert_eq!(a, b); + assert_eq!(names(&cat), ["iceland"]); + } + + #[test] + fn a_keyword_can_exist_before_any_photograph_carries_it() { + // Building the vocabulary ahead of the shoot is a real workflow, and + // it is the reason the term table exists at all. + let cat = seeded(); + create(cat.connection(), "puffin").unwrap(); + assert_eq!(names(&cat), ["puffin"]); + assert_eq!(list(cat.connection()).unwrap()[0].image_count, 0); + } + + #[test] + fn whitespace_around_a_keyword_is_not_part_of_it() { + // `query` compares with `=`, so " puffin" would be a second keyword + // that looked identical in every list and matched nothing the first + // matched. + let cat = seeded(); + assign(cat.connection(), &[img(1)], " puffin ").unwrap(); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["puffin"]); + } + + #[test] + fn inner_whitespace_collapses() { + let cat = seeded(); + assign(cat.connection(), &[img(1)], "black\tguillemot").unwrap(); + assert_eq!( + for_image(cat.connection(), img(1)).unwrap(), + ["black guillemot"] + ); + } + + #[test] + fn normalise_is_what_a_caller_can_show_the_user() { + // Public so a status line can quote the word as stored rather than as + // typed. If these two ever disagree, the UI is reporting a keyword the + // list will not show. + let cat = seeded(); + assign(cat.connection(), &[img(1)], " black \t guillemot ").unwrap(); + assert_eq!( + for_image(cat.connection(), img(1)).unwrap(), + [normalise(" black \t guillemot ").unwrap()] + ); + } + + #[test] + fn a_blank_keyword_is_refused_rather_than_stored() { + let cat = seeded(); + assert!(matches!( + assign(cat.connection(), &[img(1)], " "), + Err(CatalogError::BadName(_)) + )); + } + + #[test] + fn an_overlong_keyword_is_cut_on_a_character_boundary() { + // A mis-paste, not an intention. Truncating on a byte would panic on + // the multi-byte characters an Icelandic place name is full of. + let cat = seeded(); + let long = "Þingvellir".repeat(40); + assign(cat.connection(), &[img(1)], &long).unwrap(); + let stored = &for_image(cat.connection(), img(1)).unwrap()[0]; + assert_eq!(stored.chars().count(), MAX_KEYWORD_LEN); + } + + #[test] + fn case_is_preserved() { + // "Iceland" is a place; "iceland" is a typo of it. + let cat = seeded(); + assign(cat.connection(), &[img(1)], "Iceland").unwrap(); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["Iceland"]); + } + + #[test] + fn renaming_moves_every_assignment_with_it() { + // The failure this guards against: the vocabulary shows the new word + // and the search only finds the old one. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "Icland").unwrap(); + let id = list(cat.connection()).unwrap()[0].id; + + rename(cat.connection(), id, "Iceland").unwrap(); + + assert_eq!(names(&cat), ["Iceland"]); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["Iceland"]); + let q = crate::Query { + filter: Selector::Keyword("Iceland".into()), + ..Default::default() + }; + assert_eq!(cat.count(&q, 0).unwrap(), 2); + } + + #[test] + fn renaming_onto_an_existing_keyword_fuses_the_two() { + // Correcting a typo when the correct spelling already exists. Refusing + // would leave the user to fix it photograph by photograph. + let cat = seeded(); + assign(cat.connection(), &[img(1)], "Icland").unwrap(); + assign(cat.connection(), &[img(2)], "Iceland").unwrap(); + let typo = list(cat.connection()) + .unwrap() + .into_iter() + .find(|k| k.name == "Icland") + .unwrap() + .id; + + rename(cat.connection(), typo, "Iceland").unwrap(); + + assert_eq!(names(&cat), ["Iceland"]); + assert_eq!(list(cat.connection()).unwrap()[0].image_count, 2); + } + + #[test] + fn renaming_a_frame_that_carries_both_spellings_does_not_abort() { + // The primary key would reject a bare UPDATE for that one row and take + // the whole rename down with it. + let cat = seeded(); + assign(cat.connection(), &[img(1)], "Icland").unwrap(); + assign(cat.connection(), &[img(1)], "Iceland").unwrap(); + let typo = list(cat.connection()) + .unwrap() + .into_iter() + .find(|k| k.name == "Icland") + .unwrap() + .id; + + rename(cat.connection(), typo, "Iceland").unwrap(); + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), ["Iceland"]); + } + + #[test] + fn a_no_op_rename_is_not_an_edit() { + // A revision bumped for nothing lets an idle device win a merge + // against one that did real work. + let cat = seeded(); + let id = create(cat.connection(), "puffin").unwrap(); + let before = revision_of(&cat, id); + + assert_eq!(rename(cat.connection(), id, " puffin ").unwrap(), id); + assert_eq!(revision_of(&cat, id), before); + assert_eq!(deleted_of(&cat, id), 0, "and it does not retire the word"); + } + + #[test] + fn a_rename_retires_the_old_word_under_its_old_name() { + // The tombstone is what carries the rename to the other device, and it + // is read as "delete the assignments spelt this way" — so it has to + // keep the *old* spelling or it would delete the new word's. + let cat = seeded(); + assign(cat.connection(), &[img(1)], "Icland").unwrap(); + let old = list(cat.connection()).unwrap()[0].id; + + let new = rename(cat.connection(), old, "Iceland").unwrap(); + assert_ne!(new, old); + assert_eq!(deleted_of(&cat, old), 1); + assert_eq!(name_row(&cat, old), "Icland"); + assert!( + revision_of(&cat, old) > 1, + "the tombstone must out-revision the live row the other device holds" + ); + } + + fn revision_of(cat: &Catalog, id: KeywordId) -> i64 { + scalar(cat, "revision", id) + } + + fn deleted_of(cat: &Catalog, id: KeywordId) -> i64 { + scalar(cat, "deleted", id) + } + + /// One integer column of a term row. The column name is a literal from this + /// file and never user text — the same rule the module follows. + fn scalar(cat: &Catalog, column: &str, id: KeywordId) -> i64 { + cat.connection() + .query_row( + &format!("SELECT {column} FROM keyword_terms WHERE id = ?1"), + [id.0 as i64], + |r| r.get(0), + ) + .unwrap() + } + + fn name_row(cat: &Catalog, id: KeywordId) -> String { + cat.connection() + .query_row( + "SELECT name FROM keyword_terms WHERE id = ?1", + [id.0 as i64], + |r| r.get(0), + ) + .unwrap() + } + + #[test] + fn deleting_a_keyword_leaves_a_tombstone_and_takes_its_assignments() { + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "blurry").unwrap(); + let id = list(cat.connection()).unwrap()[0].id; + + assert_eq!(delete(cat.connection(), id).unwrap(), 2); + assert!(names(&cat).is_empty()); + assert!(for_image(cat.connection(), img(1)).unwrap().is_empty()); + + // The row survives, or a merge with a device that still holds the + // keyword would bring it straight back. + let deleted: i64 = cat + .connection() + .query_row( + "SELECT deleted FROM keyword_terms WHERE id = ?1", + [id.0 as i64], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(deleted, 1); + } + + #[test] + fn retyping_a_deleted_keyword_starts_a_fresh_identity() { + // Reusing the tombstoned row would make the new keyword inherit a + // revision that says "deleted" and lose an argument it was never in. + let cat = seeded(); + let first = create(cat.connection(), "puffin").unwrap(); + delete(cat.connection(), first).unwrap(); + + let second = create(cat.connection(), "puffin").unwrap(); + assert_ne!(first, second); + assert_eq!(names(&cat), ["puffin"]); + } + + #[test] + fn coverage_distinguishes_all_from_some() { + // The dash rather than the tick. Applying a word to forty frames where + // thirty already have it must not look like applying it to forty that + // do not. + let cat = seeded(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + assign(cat.connection(), &[img(1)], "nest").unwrap(); + + let rows = for_images(cat.connection(), &[img(1), img(2)]).unwrap(); + let puffin = rows.iter().find(|r| r.keyword.name == "puffin").unwrap(); + let nest = rows.iter().find(|r| r.keyword.name == "nest").unwrap(); + + assert_eq!(puffin.coverage, Coverage::All); + assert_eq!(nest.coverage, Coverage::Some); + assert_eq!(nest.selected_count, 1); + } + + #[test] + fn a_keyword_no_one_in_the_selection_has_still_appears() { + // It is a target, not a report: the list is what the user assigns + // from, so hiding the unused words would hide the point of it. + let cat = seeded(); + create(cat.connection(), "puffin").unwrap(); + let rows = for_images(cat.connection(), &[img(1)]).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].coverage, Coverage::None); + } + + #[test] + fn the_vocabulary_leads_with_the_words_actually_used() { + let cat = seeded(); + create(cat.connection(), "unused").unwrap(); + assign(cat.connection(), &[img(1), img(2)], "puffin").unwrap(); + assert_eq!(names(&cat), ["puffin", "unused"]); + } + + #[test] + fn a_word_on_two_versions_counts_as_one_photograph() { + // A count that double-counts virtual copies is the kind of small lie + // that makes a user stop trusting the numbers. + let cat = seeded(); + let c = cat.connection(); + assign(c, &[img(1)], "puffin").unwrap(); + c.execute( + "INSERT INTO versions(image_id, uuid, name, is_default) VALUES (1, 'copy', 'Crop', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'puffin')", + [c.last_insert_rowid()], + ) + .unwrap(); + + assert_eq!(list(c).unwrap()[0].image_count, 1); + } + + #[test] + fn an_image_with_no_version_gains_one_rather_than_losing_the_keyword() { + // A library scanned before versions existed. The user typed a word and + // expects it to stick. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) + VALUES (99, 1, 'IMG_99.CR3', 0)", + [], + ) + .unwrap(); + + assign(c, &[img(99)], "puffin").unwrap(); + assert_eq!(for_image(c, img(99)).unwrap(), ["puffin"]); + } + + #[test] + fn a_hostile_keyword_is_stored_rather_than_executed() { + // The write side of `query`'s injection guard. It goes in as a + // parameter, comes back out unchanged, and the table is still there. + let cat = seeded(); + let evil = "'; DROP TABLE images; --"; + assign(cat.connection(), &[img(1)], evil).unwrap(); + + assert_eq!(for_image(cat.connection(), img(1)).unwrap(), [evil]); + let n: i64 = cat + .connection() + .query_row("SELECT count(*) FROM images", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 6); + } + + #[test] + fn words_that_predate_the_vocabulary_are_adopted() { + // A catalog rebuilt from sidecars, or keyworded by an older build. + let cat = seeded(); + let c = cat.connection(); + let version = crate::rating::default_version_id(c, img(1)).unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'seabird')", + [version], + ) + .unwrap(); + + assert_eq!(adopt_orphan_terms(c).unwrap(), 1); + assert_eq!(names(&cat), ["seabird"]); + // It runs on every open, so a second pass must find nothing to do. + assert_eq!(adopt_orphan_terms(c).unwrap(), 0); + } + + #[test] + fn an_orphaned_word_is_still_shown_before_it_is_adopted() { + let cat = seeded(); + let c = cat.connection(); + let version = crate::rating::default_version_id(c, img(1)).unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (?1, 'seabird')", + [version], + ) + .unwrap(); + + let rows = for_images(c, &[img(1)]).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].keyword.name, "seabird"); + assert_eq!(rows[0].keyword.id, KeywordId(0), "no identity yet"); + } + + #[test] + fn duplicate_identities_for_one_word_fuse_onto_the_smaller_uuid() { + // Two devices each typed "Iceland" before they had ever synced. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified) + VALUES ('aaaa', 'Iceland', 0, 1, 1), ('zzzz', 'Iceland', 0, 9, 9)", + [], + ) + .unwrap(); + + assert_eq!(fuse_duplicates(c).unwrap(), 1); + let survivor: String = c + .query_row("SELECT uuid FROM keyword_terms", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + survivor, "aaaa", + "the rule must not consult the revision, or two devices pick differently" + ); + } + + #[test] + fn fusing_leaves_a_tombstone_alone() { + // A tombstone is the user's deletion. Retiring it as a duplicate would + // quietly undo it. + let cat = seeded(); + let c = cat.connection(); + c.execute( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified, deleted) + VALUES ('aaaa', 'Iceland', 0, 1, 1, 0), ('zzzz', 'Iceland', 0, 9, 9, 1)", + [], + ) + .unwrap(); + + assert_eq!(fuse_duplicates(c).unwrap(), 0); + } + + #[test] + fn an_empty_selection_still_creates_the_keyword() { + // Typing a word into the field with nothing selected builds the + // vocabulary, which is a legitimate thing to do ahead of a shoot. + let cat = seeded(); + assert_eq!(assign(cat.connection(), &[], "puffin").unwrap(), 0); + assert_eq!(names(&cat), ["puffin"]); + } + + #[test] + fn renaming_a_keyword_that_is_gone_says_so() { + let cat = seeded(); + assert!(matches!( + rename(cat.connection(), KeywordId(404), "x"), + Err(CatalogError::NoSuchKeyword(404)) + )); + } +} diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index aca35d4..8a0994a 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -14,9 +14,10 @@ //! - [`walk`] — those decisions driven against real storage, local or SAF //! - [`query`] — selectors compiled to indexed SQL, windowed for the grid //! - [`collections`] — the collection tree and membership the UI edits +//! - [`keywords`] — the keyword vocabulary and what it is assigned to //! - [`jobs`] — the durable background work queue //! - [`trash`] — soft delete to a folder, then permanent delete -//! - [`merge`] / [`sync`] — cross-device collection merging +//! - [`merge`] / [`sync`] — cross-device merging of collections and keywords //! //! # The one thing everything is designed around //! @@ -35,6 +36,7 @@ pub mod cache; pub mod collections; pub mod error; pub mod jobs; +pub mod keywords; pub mod merge; pub mod query; pub mod rating; @@ -48,6 +50,7 @@ pub use cache::{Budget, Cache, DEFAULT_BUDGET_BYTES}; pub use collections::{Collection, CollectionKind, TreeRow}; pub use error::CatalogError; pub use jobs::{Job, JobKind, Priority}; +pub use keywords::{Coverage, Keyword, KeywordId, SelectionKeyword}; pub use merge::MergeReport; pub use query::{Query, Sort}; pub use rating::{Judgement, MAX_RATING}; diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index da13480..271f772 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -15,7 +15,7 @@ use rusqlite::Connection; use crate::error::CatalogError; /// Schema version this build writes and understands. -pub const SCHEMA_VERSION: i64 = 5; +pub const SCHEMA_VERSION: i64 = 6; /// Apply migrations up to [`SCHEMA_VERSION`]. /// @@ -66,6 +66,12 @@ pub fn migrate(conn: &Connection) -> Result { tx.pragma_update(None, "user_version", 5)?; tx.commit()?; } + if from < 6 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V6)?; + tx.pragma_update(None, "user_version", 6)?; + tx.commit()?; + } Ok(from) } @@ -103,6 +109,20 @@ pub fn backfill(conn: &Connection) -> Result, Catalog out.push(("default_versions", n)); } + // v6: a vocabulary row for every word some image already carries. + // + // Three ways a catalog arrives holding assignments with no term behind + // them, and all three are normal rather than exceptional: a library + // keyworded by a build that predates this table, a catalog rebuilt from + // sidecars (which carry the word and not the identity), and an import from + // Lightroom or darktable (FR-CAT-14). Without this the words are + // searchable but absent from the vocabulary list, which reads as the + // keywords having been lost. + let n = crate::keywords::adopt_orphan_terms(conn)?; + if n > 0 { + out.push(("keyword_terms", n)); + } + Ok(out) } @@ -134,15 +154,45 @@ pub fn configure(conn: &Connection) -> Result<(), CatalogError> { /// in [`V1`]: every `CREATE TABLE`/`CREATE INDEX` must name its object /// unqualified, which they do. pub fn v1_for_attached(schema_name: &str) -> String { - V1.replace("CREATE TABLE ", &format!("CREATE TABLE {schema_name}.")) + rewrite_for_attached(V1, schema_name) + // REFERENCES within an attached schema resolve to that schema already, + // so foreign keys need no rewriting — but the ON clause of an index + // does, and `CREATE INDEX x.name ON table` is the correct form. +} + +/// Every table this build knows about, rewritten to target an attached +/// database. +/// +/// [`v1_for_attached`] is kept alongside this rather than replaced by it: a +/// remote catalog written by an older build genuinely has only the v1 tables, +/// and the merge has to keep working against one (see +/// [`crate::merge::merge_keywords`]). Building that case in a test needs a way +/// to say "v1 and no more". +/// +/// Only the migrations that *create* objects appear here. V2 through V5 are +/// `ALTER TABLE ... ADD COLUMN`, and the columns they add are local index +/// state — shadowing, trashing, cache pinning — that a merge never reads +/// across the attachment. +pub fn for_attached(schema_name: &str) -> String { + format!( + "{}\n{}", + rewrite_for_attached(V1, schema_name), + rewrite_for_attached(V6, schema_name) + ) +} + +/// Qualify every object a `CREATE` statement names with `schema_name`. +/// +/// The rewrite is textual and therefore only as good as the naming discipline +/// in the batches it is given: every `CREATE TABLE`/`CREATE INDEX` must name +/// its object unqualified, which they do. +fn rewrite_for_attached(sql: &str, schema_name: &str) -> String { + sql.replace("CREATE TABLE ", &format!("CREATE TABLE {schema_name}.")) .replace("CREATE INDEX ", &format!("CREATE INDEX {schema_name}.")) .replace( "CREATE UNIQUE INDEX ", &format!("CREATE UNIQUE INDEX {schema_name}."), ) - // REFERENCES within an attached schema resolve to that schema already, - // so foreign keys need no rewriting — but the ON clause of an index - // does, and `CREATE INDEX x.name ON table` is the correct form. } /// Mark each JPEG that sits beside a RAW of the same name. @@ -226,6 +276,63 @@ fn stem_of(path: &str) -> &str { } } +const V6: &str = r#" +-- TRACES: FR-CAT-5 | FR-CAT-6 | FR-NC-9 +-- Keywords gain an identity, so that renaming and deleting one can cross +-- between devices. +-- +-- The v1 `keywords` table is the *assignment*: one row per (version, word), +-- and the word is stored as text. That stays exactly as it is, and this +-- migration adds nothing to it, for a reason that is easy to get backwards. +-- +-- # Why assignments keep the text rather than pointing at a row here +-- +-- The catalog is a rebuildable index (ARCH §6.12). What an image is keyworded +-- with is authoritative in the sidecar and in XMP `dc:subject` (FR-CAT-13), +-- and both of those carry a *string*. Rewriting the join to reference +-- `keyword_terms(id)` would mean a catalog rebuilt from sidecars had to invent +-- term rows before it could record a single assignment, and an integer that +-- means nothing on the other device would sit where the durable fact belongs. +-- It would also break `crate::query`, which matches `kw.keyword` directly and +-- must keep hitting `keywords_term` on a 50k library (FR-CAT-6). +-- +-- So the text is the fact and this table is the *identity*: it exists to give +-- a rename and a deletion something a merge can key on, and to let a keyword +-- exist in the vocabulary before any photograph carries it. +CREATE TABLE keyword_terms ( + id INTEGER PRIMARY KEY, + -- Device-independent identity, as `collections.uuid` is. The integer id is + -- local and collides across devices. + uuid TEXT NOT NULL UNIQUE, + -- The word itself, and the value written into every assignment row. + name TEXT NOT NULL, + created INTEGER NOT NULL, + -- Monotonic, bumped on every local edit. `crate::merge` compares these + -- rather than timestamps, so a clock-skewed device cannot silently win. + revision INTEGER NOT NULL DEFAULT 1, + modified INTEGER NOT NULL, + -- Tombstone, so a merge against a device that still holds the keyword does + -- not resurrect it. + deleted INTEGER NOT NULL DEFAULT 0 +); + +-- Deliberately **not** UNIQUE. +-- +-- Two devices that each type "Iceland" create two rows with two uuids, and +-- both are correct until they meet. A unique constraint would abort the merge +-- transaction at exactly that moment — the ordinary case, not a corner one. +-- Uniqueness is instead reached by convergence: `crate::keywords::create` +-- resolves an existing name locally, and `crate::keywords::fuse_duplicates` +-- collapses a cross-device pair onto the lexicographically smaller uuid, which +-- both devices compute identically without talking to each other. +-- +-- Partial on `deleted = 0` because every lookup here is a live one: the +-- vocabulary list, the resolve-by-name in `create`, and the fuse pass all +-- exclude tombstones, and including them would grow the index with every +-- keyword the library has ever had rather than with the ones it has. +CREATE INDEX keyword_terms_name ON keyword_terms(name) WHERE deleted = 0; +"#; + const V5: &str = r#" -- TRACES: FR-NC-6a | FR-CAT-9 | NFR-RES-4 -- Offline availability: what is kept, why it is kept, and where it lives. @@ -671,6 +778,77 @@ mod tests { assert_eq!(bytes, 100, "the existing row is untouched"); } + #[test] + fn a_v5_catalog_keeps_its_keywords_and_gains_their_identities() { + // TRACES: FR-CAT-5 + // The migration case that matters here: a library keyworded by an + // import or an older build already has assignment rows, and they must + // survive into the vocabulary rather than being left searchable but + // invisible. + let c = mem(); + for step in [V1, V2, V3, V4, V5] { + c.execute_batch(step).unwrap(); + } + c.pragma_update(None, "user_version", 5).unwrap(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (7, 1, 'IMG_7.CR3', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO versions(id, image_id, uuid, name, is_default) + VALUES (1, 7, 'v-7', 'Default', 1)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO keywords(version_id, keyword) VALUES (1, 'puffin')", + [], + ) + .unwrap(); + + assert_eq!(migrate(&c).unwrap(), 5, "migrated from v5"); + assert_eq!(backfilled(&c, "keyword_terms"), 1); + + let name: String = c + .query_row("SELECT name FROM keyword_terms", [], |r| r.get(0)) + .unwrap(); + assert_eq!(name, "puffin"); + // The assignment is untouched — it is the durable fact, and the term + // row is only its identity. + let n: i64 = c + .query_row("SELECT count(*) FROM keywords", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1); + + // It runs on every open, so a second pass must find nothing to do. + assert_eq!(backfilled(&c, "keyword_terms"), 0); + } + + #[test] + fn two_devices_may_both_hold_a_term_of_the_same_name() { + // Deliberately not a unique index. Two devices each typing "Iceland" + // is the ordinary case, and a constraint would abort the merge + // transaction at exactly the moment they first sync. + let c = mem(); + migrate(&c).unwrap(); + c.execute( + "INSERT INTO keyword_terms(uuid, name, created, revision, modified) + VALUES ('a', 'Iceland', 0, 1, 1), ('b', 'Iceland', 0, 1, 1)", + [], + ) + .unwrap(); + let n: i64 = c + .query_row("SELECT count(*) FROM keyword_terms", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 2); + } + #[test] fn stems_ignore_directories_containing_dots() { assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1"); From 62188ec7400d5e2002ff31b42fd2597809c2f1b9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 15:51:00 +0200 Subject: [PATCH 02/27] Keep both devices' keywords when the catalogs meet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keywords are catalog state, and the catalog syncs. Without this, two devices keywording the same library would resolve to whichever synced last, and an afternoon of work would vanish with no sign it had ever happened. The vocabulary merges per row on the rule collections already use: revision first, timestamp only to break a tie, so a device with a skewed clock cannot win by having the wrong idea of the time. Assignments merge as a set union, which is FR-NC-9's principle applied to metadata instead of edit nodes — disjoint work survives on both sides. Three things needed care and are commented where they happen: A deletion travels *by name*, not by identity. Both devices may have minted their own uuid for one word before they ever synced, so deleting by uuid would tombstone a row nothing was assigned to and leave every photograph still carrying the word. The union then refuses to readmit a word a winning tombstone has just removed — without that filter the remote's live assignments would resurrect it on the very same pass. Images are resolved by the server's file id first and the content hash second. Membership has always used the hash alone, but the hash is computed only when import dedup or a reconnect asks for it, which for most libraries is never — so a hash-only union would have quietly done nothing for the ordinary photograph. A word lands on the local default version. Version uuids do not reconcile in the catalog at all: ensure_default_versions mints a fresh one per device, so a uuid-keyed join would have unioned nothing. Removal still does not propagate. That is the trade collection membership already makes, for the same reason — an unwanted keyword is removed again in a second, and a silently lost afternoon is not recoverable at all. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-catalog/src/merge.rs | 682 +++++++++++++++++++++++++++++++++- core/dr-catalog/src/rating.rs | 25 +- core/dr-catalog/src/sync.rs | 11 +- 3 files changed, 698 insertions(+), 20 deletions(-) diff --git a/core/dr-catalog/src/merge.rs b/core/dr-catalog/src/merge.rs index 710553b..7a1e50c 100644 --- a/core/dr-catalog/src/merge.rs +++ b/core/dr-catalog/src/merge.rs @@ -1,5 +1,5 @@ -//! TRACES: FR-CAT-7 | FR-NC-9 -//! Merging a remote catalog's collections into the local one. +//! TRACES: FR-CAT-7 | FR-CAT-5 | FR-NC-9 +//! Merging a remote catalog's collections and keywords into the local one. //! //! # Why this is a merge and not a copy //! @@ -33,6 +33,35 @@ //! against a device that still holds it would otherwise resurrect it. The //! tombstone carries a revision like any other edit, so deletion competes on //! the same footing as a rename. +//! +//! # Keywords merge on the same three rules +//! +//! [`merge_keywords`] reuses all of the above rather than inventing a second +//! set of rules, because a keyword is the same shape of problem as a +//! collection: a named thing with a device-independent identity, and a +//! many-to-many join to images. +//! +//! - The **vocabulary** (`keyword_terms`) is decided per row by [`verdict`], +//! exactly as collections are. +//! - The **assignments** (`keywords`) are a set union, exactly as membership +//! is: two devices each keywording different photographs "puffin" keep both +//! sets, and two devices each keywording the *same* photograph converge on +//! one row rather than one of them winning. +//! - **Deletion** tombstones, and takes the assignments with it. +//! +//! Two things are genuinely different, and both are consequences of assignments +//! storing the *word* rather than a row id: +//! +//! 1. A tombstone deletes assignments **by name**, so a deletion still lands on +//! a device that had minted its own identity for the same word. The union +//! then refuses to readmit a word a winning tombstone has just removed — +//! without that filter, the other device's live assignments would resurrect +//! it on the very same pass. +//! 2. Two devices that independently typed the same word arrive with two uuids +//! for one keyword. [`crate::keywords::fuse_duplicates`] collapses them onto +//! the lexicographically smaller one, which both devices compute identically. +//! A unique index on the name would instead abort the merge transaction at +//! that moment, which is the ordinary case rather than a corner one. use rusqlite::Connection; @@ -59,18 +88,44 @@ pub struct MergeReport { pub kept_local: usize, pub deleted: usize, pub members_added: usize, + + // Keywords are counted separately from collections rather than summed into + // the same fields. The report is shown to the user — "3 collections, 11 + // keywords" is a sentence; "14 things" is not — and a merge that went wrong + // is far easier to place when the counts say which half it went wrong in. + /// Keywords the remote had and this device did not. + pub keywords_inserted: usize, + /// Keywords the remote had renamed, or brought back from a tombstone. + pub keywords_updated: usize, + /// Keywords the remote deleted, and this device has now deleted too. + pub keywords_deleted: usize, + /// Keywords where this device's revision was at least as high. + pub keywords_kept_local: usize, + /// Redundant identities for one word, retired by + /// [`crate::keywords::fuse_duplicates`]. + pub keywords_fused: usize, + /// Keyword assignments taken from the remote. + pub keywords_assigned: usize, } impl MergeReport { /// Whether the local catalog changed, and so needs re-uploading. pub fn local_changed(&self) -> bool { - self.inserted > 0 || self.updated > 0 || self.deleted > 0 || self.members_added > 0 + self.inserted > 0 + || self.updated > 0 + || self.deleted > 0 + || self.members_added > 0 + || self.keywords_inserted > 0 + || self.keywords_updated > 0 + || self.keywords_deleted > 0 + || self.keywords_fused > 0 + || self.keywords_assigned > 0 } /// Whether the local catalog holds anything the remote did not, and so /// must be uploaded even if nothing was taken from the remote. pub fn should_upload(&self) -> bool { - self.kept_local > 0 || self.local_changed() + self.kept_local > 0 || self.keywords_kept_local > 0 || self.local_changed() } } @@ -108,16 +163,55 @@ pub fn verdict( } } -/// Merge collections and membership from an attached catalog. +/// Merge everything that syncs, from an attached catalog. /// /// The remote catalog must already be attached under the schema name -/// `remote_cat`; [`crate::Catalog::merge_attached_collections`] handles that. +/// `remote_cat`; [`crate::sync::merge_remote`] handles that. +/// +/// **One transaction over both halves.** Keywords and collections are +/// independent as data, but a merge that landed the collections and then failed +/// on the keywords would leave a catalog that has already taken the remote's +/// revisions for half of itself — and the next attempt, seeing those revisions, +/// would decline to take them again. Half a merge is not a state that can be +/// resumed, so it is not a state that can be reached. +pub fn merge_all(conn: &Connection) -> Result { + let tx = conn.unchecked_transaction()?; + let mut report = MergeReport::default(); + merge_collections_within(&tx, &mut report)?; + merge_keywords_within(&tx, &mut report)?; + tx.commit()?; + Ok(report) +} + +/// Merge collections and membership from an attached catalog. +/// +/// The collections half of [`merge_all`], on its own. Kept as a public entry +/// point because the two halves are genuinely independent, and because the +/// rules for this one are worth being able to exercise without a keyword in +/// sight. /// /// Runs in one transaction: a merge either lands whole or not at all. pub fn merge_collections(conn: &Connection) -> Result { let tx = conn.unchecked_transaction()?; let mut report = MergeReport::default(); + merge_collections_within(&tx, &mut report)?; + tx.commit()?; + Ok(report) +} +/// Merge the keyword vocabulary and its assignments from an attached catalog. +/// +/// The keywords half of [`merge_all`], on its own. See the module header for +/// the three rules and the two places keywords differ from collections. +pub fn merge_keywords(conn: &Connection) -> Result { + let tx = conn.unchecked_transaction()?; + let mut report = MergeReport::default(); + merge_keywords_within(&tx, &mut report)?; + tx.commit()?; + Ok(report) +} + +fn merge_collections_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { // ---- collections ------------------------------------------------------ { let mut stmt = tx.prepare( @@ -310,8 +404,245 @@ pub fn merge_collections(conn: &Connection) -> Result )?; report.members_added = added; - tx.commit()?; - Ok(report) + Ok(()) +} + +/// Schema name the downloaded remote catalog is attached under. +/// +/// Repeated from [`crate::sync`] rather than shared, because the SQL below +/// spells it inline and a constant that only half the file used would be worse +/// than no constant at all. +const REMOTE: &str = "remote_cat"; + +/// The keyword half. See the module header. +fn merge_keywords_within(tx: &Connection, report: &mut MergeReport) -> Result<(), CatalogError> { + // ---- the vocabulary --------------------------------------------------- + // + // A remote written before schema v6 has no `keyword_terms` at all, and + // `remote_is_mergeable` deliberately admits it: the check is that the + // remote is not *newer* than us. So the table's absence is a normal state + // and not an error. Its assignments still merge below — those have been in + // the schema since v1 — and its words gain identities on that device the + // next time it opens the catalog and backfills. + if attached_has_table(tx, REMOTE, "keyword_terms")? { + struct Incoming { + uuid: String, + name: String, + /// What this device currently calls the same identity, if it has + /// it. A rename is applied to the assignment rows by rewriting this + /// text, so it has to be read before the term row is overwritten. + local_name: Option, + created: i64, + revision: i64, + modified: i64, + verdict: MergeVerdict, + } + + let rows: Vec = { + let mut stmt = tx.prepare( + "SELECT r.uuid, r.name, r.created, r.revision, r.modified, r.deleted, + l.name, l.revision, l.modified + FROM remote_cat.keyword_terms r + LEFT JOIN main.keyword_terms l ON l.uuid = r.uuid", + )?; + let found = stmt + .query_map([], |r| { + let deleted: i64 = r.get(5)?; + let local_rev: Option = r.get(7)?; + let local_mod: Option = r.get(8)?; + let revision: i64 = r.get(3)?; + let modified: i64 = r.get(4)?; + Ok(Incoming { + uuid: r.get(0)?, + name: r.get(1)?, + local_name: r.get(6)?, + created: r.get(2)?, + revision, + modified, + verdict: verdict( + local_rev.zip(local_mod), + (revision, modified), + deleted != 0, + ), + }) + })? + .collect::, _>>()?; + found + }; + + for row in rows { + match row.verdict { + MergeVerdict::KeptLocal => { + report.keywords_kept_local += 1; + } + MergeVerdict::InsertedFromRemote => { + tx.execute( + "INSERT INTO main.keyword_terms + (uuid, name, created, revision, modified, deleted) + VALUES (?1, ?2, ?3, ?4, ?5, 0)", + rusqlite::params![ + row.uuid, + row.name, + row.created, + row.revision, + row.modified, + ], + )?; + report.keywords_inserted += 1; + } + MergeVerdict::UpdatedFromRemote => { + // The assignments carry the *word*, so taking a new name + // for an identity we already hold means rewriting every row + // spelt the old way. Without this the vocabulary would show + // the new spelling and the search would only find the old. + if let Some(old) = row.local_name.filter(|n| *n != row.name) { + tx.execute( + "INSERT OR IGNORE INTO main.keywords(version_id, keyword) + SELECT version_id, ?2 FROM main.keywords WHERE keyword = ?1", + rusqlite::params![old, row.name], + )?; + tx.execute("DELETE FROM main.keywords WHERE keyword = ?1", [&old])?; + } + tx.execute( + "UPDATE main.keyword_terms + SET name = ?2, revision = ?3, modified = ?4, deleted = 0 + WHERE uuid = ?1", + rusqlite::params![row.uuid, row.name, row.revision, row.modified], + )?; + report.keywords_updated += 1; + } + MergeVerdict::DeletedByRemote => { + // Tombstone rather than DELETE, or a third device + // reintroduces the keyword through us. + tx.execute( + "INSERT INTO main.keyword_terms + (uuid, name, created, revision, modified, deleted) + VALUES (?1, ?2, ?3, ?4, ?5, 1) + ON CONFLICT(uuid) DO UPDATE SET + deleted = 1, revision = ?4, modified = ?5", + rusqlite::params![ + row.uuid, + row.name, + row.created, + row.revision, + row.modified, + ], + )?; + // **By name, not by identity.** This device may well have + // minted its own uuid for the same word before the two ever + // synced, in which case deleting by uuid would tombstone a + // row that nothing is assigned to and leave every + // photograph still carrying the word. + tx.execute("DELETE FROM main.keywords WHERE keyword = ?1", [&row.name])?; + report.keywords_deleted += 1; + } + } + } + + // Two devices that each typed "Iceland" now hold two identities for one + // word. Collapse them before the assignments arrive, so the vocabulary + // the user sees after a sync has one row per word. + report.keywords_fused = crate::keywords::fuse_duplicates(tx)?; + } + + // ---- assignments ------------------------------------------------------ + // + // Set union, and the union is the whole point: FR-NC-9's principle applied + // to metadata rather than to edit nodes. Two devices that keyworded + // different frames "puffin" both keep their work, and neither loses it to + // whichever synced second. + // + // A removal therefore does not propagate — the remote's assignment simply + // reappears. That is the same trade-off collection membership makes above, + // and for the same reason: an unwanted keyword is removed again in a + // second, and a silently lost afternoon of keywording is not recoverable at + // all. Making removal propagate needs a tombstone per assignment, which is + // a schema change and a merge rule of its own. + // + // An incoming keyword lands on the local default version, so an image that + // has not got one yet would silently drop it. That is not a rare state: the + // invariant is maintained by a backfill on open, and a scan that ran since + // has added rows it has not covered. Losing a word the user typed on + // another device, for a bookkeeping reason, would be the wrong answer — + // this is idempotent and writes nothing once the invariant holds. + crate::rating::ensure_default_versions_within(tx)?; + + // Two passes rather than one statement with an `OR`, because they resolve + // *different identities* for the same photograph and each wants its own + // index. See [`ASSIGN_BY_FILE_ID`] for why there are two at all. + for sql in [ASSIGN_BY_FILE_ID, ASSIGN_BY_CONTENT_HASH] { + report.keywords_assigned += tx.execute(sql, [])?; + } + + Ok(()) +} + +/// Take assignments for images both devices know by the server's file id. +/// +/// **Preferred over the content hash**, and the reason is that `content_hash` +/// is expensive — the schema says so, and it is computed only when import +/// dedup or a reconnect asks for it, which for most libraries is never. Keying +/// keywords on it alone would mean the union quietly did nothing for the +/// ordinary image, which is the exact failure this merge exists to prevent. +/// +/// `oc:fileid` is the opposite: it is recorded for every image the moment a +/// remote scan sees it, it is stable across server-side renames and moves, and +/// it is the same integer on every device pointed at the same Nextcloud — which +/// is precisely the situation where two devices are keywording one library. +/// +/// The keyword lands on the local image's **default version**, not on the +/// version it came from. Version uuids do not reconcile across devices in the +/// catalog: [`crate::rating::ensure_default_versions`] mints a fresh one per +/// device, so the same photograph's default versions have different uuids on +/// two machines and a uuid-keyed join would union nothing at all. Version +/// identity is reconciled in the *sidecar* (FR-NC-8), and until a merged +/// version arrives through there, the default version is both where +/// [`crate::keywords::assign`] writes and where the panel reads — so it is the +/// one place the word can land and be seen. +const ASSIGN_BY_FILE_ID: &str = " + INSERT OR IGNORE INTO main.keywords(version_id, keyword) + SELECT lv.id, rk.keyword + FROM remote_cat.keywords rk + JOIN remote_cat.versions rv ON rv.id = rk.version_id + JOIN remote_cat.remote rr ON rr.image_id = rv.image_id + JOIN main.remote lr ON lr.file_id = rr.file_id + JOIN main.versions lv ON lv.image_id = lr.image_id AND lv.is_default = 1 + WHERE NOT EXISTS (SELECT 1 FROM main.keyword_terms t + WHERE t.name = rk.keyword AND t.deleted = 1) + OR EXISTS (SELECT 1 FROM main.keyword_terms t + WHERE t.name = rk.keyword AND t.deleted = 0)"; + +/// The same union for a library with no server behind it. +/// +/// A local-only library has no `remote` rows at all, so [`ASSIGN_BY_FILE_ID`] +/// matches nothing and this is the only identity available — and it is the one +/// collection membership already uses, so a library where membership merges +/// has keywords that merge too. +const ASSIGN_BY_CONTENT_HASH: &str = " + INSERT OR IGNORE INTO main.keywords(version_id, keyword) + SELECT lv.id, rk.keyword + FROM remote_cat.keywords rk + JOIN remote_cat.versions rv ON rv.id = rk.version_id + JOIN remote_cat.images ri ON ri.id = rv.image_id + JOIN main.images li ON li.content_hash = ri.content_hash + JOIN main.versions lv ON lv.image_id = li.id AND lv.is_default = 1 + WHERE ri.content_hash IS NOT NULL + AND (NOT EXISTS (SELECT 1 FROM main.keyword_terms t + WHERE t.name = rk.keyword AND t.deleted = 1) + OR EXISTS (SELECT 1 FROM main.keyword_terms t + WHERE t.name = rk.keyword AND t.deleted = 0))"; + +/// Whether an attached database holds a table of this name. +/// +/// The schema name and the table name are both literals from this file, never +/// user text — but they are still bound rather than formatted where SQLite +/// allows it, because the habit is what keeps the one that eventually is user +/// text from being formatted by accident. +fn attached_has_table(conn: &Connection, schema: &str, table: &str) -> Result { + let sql = + format!("SELECT count(*) FROM {schema}.sqlite_master WHERE type = 'table' AND name = ?1"); + let n: i64 = conn.query_row(&sql, [table], |r| r.get(0))?; + Ok(n > 0) } #[cfg(test)] @@ -391,12 +722,21 @@ mod tests { // ---- integration over two real catalogs ------------------------------ fn two_catalogs() -> Connection { + attached_remote(schema::for_attached("remote_cat")) + } + + /// The same pair, but with the remote stopped at v1 — a device running a + /// build from before keywords had identities. + fn two_catalogs_with_a_v1_remote() -> Connection { + attached_remote(schema::v1_for_attached("remote_cat")) + } + + fn attached_remote(remote_schema: String) -> Connection { let c = Connection::open_in_memory().unwrap(); schema::configure(&c).unwrap(); schema::migrate(&c).unwrap(); // A second in-memory database standing in for the downloaded remote. c.execute_batch("ATTACH ':memory:' AS remote_cat").unwrap(); - let remote_schema = super::super::schema::v1_for_attached("remote_cat"); c.execute_batch(&remote_schema).unwrap(); c } @@ -656,6 +996,330 @@ mod tests { assert!(!second.local_changed(), "merge must be idempotent"); } + // ---- keywords -------------------------------------------------------- + + /// Give an image a default version, as every write path assumes it has. + fn add_version(c: &Connection, db: &str, image: i64, uuid: &str) -> i64 { + c.execute( + &format!( + "INSERT INTO {db}.versions(image_id, uuid, name, is_default) + VALUES (?1, ?2, 'Default', 1)" + ), + rusqlite::params![image, uuid], + ) + .unwrap(); + c.last_insert_rowid() + } + + /// Put a word on an image's default version, creating the version. + /// + /// The version uuid is derived from the database *and* the image, so the + /// two catalogs never accidentally agree on one — which is the real + /// situation, and the reason the assignment union cannot key on it. + fn keyword(c: &Connection, db: &str, image: i64, word: &str) { + let existing: Option = c + .query_row( + &format!("SELECT id FROM {db}.versions WHERE image_id = ?1 AND is_default = 1"), + [image], + |r| r.get(0), + ) + .ok(); + let version = + existing.unwrap_or_else(|| add_version(c, db, image, &format!("v-{db}-{image}"))); + c.execute( + &format!("INSERT OR IGNORE INTO {db}.keywords(version_id, keyword) VALUES (?1, ?2)"), + rusqlite::params![version, word], + ) + .unwrap(); + } + + fn add_term(c: &Connection, db: &str, uuid: &str, name: &str, rev: i64, deleted: i64) { + c.execute( + &format!( + "INSERT INTO {db}.keyword_terms(uuid, name, created, revision, modified, deleted) + VALUES (?1, ?2, 0, ?3, ?3, ?4)" + ), + rusqlite::params![uuid, name, rev, deleted], + ) + .unwrap(); + } + + /// Map an image to a server file id, as a remote scan does. + fn add_file_id(c: &Connection, db: &str, image: i64, file_id: i64) { + c.execute( + &format!("INSERT INTO {db}.remote(image_id, file_id) VALUES (?1, ?2)"), + rusqlite::params![image, file_id], + ) + .unwrap(); + } + + /// Every word on an image locally, sorted. + fn words_on(c: &Connection, image: i64) -> Vec { + let mut stmt = c + .prepare( + "SELECT DISTINCT k.keyword FROM main.keywords k + JOIN main.versions v ON v.id = k.version_id + WHERE v.image_id = ?1 ORDER BY k.keyword", + ) + .unwrap(); + let rows = stmt.query_map([image], |r| r.get(0)).unwrap(); + rows.collect::, _>>().unwrap() + } + + fn live_terms(c: &Connection) -> Vec { + let mut stmt = c + .prepare("SELECT name FROM main.keyword_terms WHERE deleted = 0 ORDER BY name") + .unwrap(); + let rows = stmt.query_map([], |r| r.get(0)).unwrap(); + rows.collect::, _>>().unwrap() + } + + #[test] + fn two_devices_keywording_different_photographs_both_survive() { + // FR-NC-9's principle applied to metadata: disjoint work merges to the + // union, and neither device loses an afternoon to whoever synced last. + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + add_image(&c, db, 2, "hash-b"); + } + keyword(&c, "main", 1, "puffin"); + keyword(&c, "remote_cat", 2, "gannet"); + + merge_keywords(&c).unwrap(); + + assert_eq!(words_on(&c, 1), ["puffin"]); + assert_eq!(words_on(&c, 2), ["gannet"]); + } + + #[test] + fn two_devices_keywording_one_photograph_keep_both_words() { + // The case the union is really for: the same frame, two different + // words, and last-writer-wins would silently drop one of them. + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + keyword(&c, "main", 1, "puffin"); + keyword(&c, "remote_cat", 1, "Iceland"); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_assigned, 1); + assert_eq!(words_on(&c, 1), ["Iceland", "puffin"]); + } + + #[test] + fn a_word_both_devices_already_had_is_not_duplicated() { + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + keyword(&c, db, 1, "puffin"); + } + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_assigned, 0); + assert_eq!(words_on(&c, 1), ["puffin"]); + } + + #[test] + fn keywords_reach_an_image_the_server_names_but_no_one_has_hashed() { + // `content_hash` is computed only when import dedup or a reconnect asks + // for it, so for most images it is NULL — and a union keyed on it alone + // would quietly do nothing for the ordinary photograph. The file id is + // recorded by every remote scan, which is exactly the situation where + // two devices are keywording one library. + let c = two_catalogs(); + c.execute( + "INSERT INTO main.roots(id, kind, label) VALUES (1, 'remote', 'r')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO remote_cat.roots(id, kind, label) VALUES (1, 'remote', 'r')", + [], + ) + .unwrap(); + // Different row ids for one photograph, and no hash on either side. + c.execute( + "INSERT INTO main.images(id, root_id, source_ref, added_at) + VALUES (77, 1, 'IMG_1.CR3', 0)", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO remote_cat.images(id, root_id, source_ref, added_at) + VALUES (3, 1, 'IMG_1.CR3', 0)", + [], + ) + .unwrap(); + add_file_id(&c, "main", 77, 9001); + add_file_id(&c, "remote_cat", 3, 9001); + add_version(&c, "main", 77, "v-main"); + keyword(&c, "remote_cat", 3, "puffin"); + + merge_keywords(&c).unwrap(); + assert_eq!(words_on(&c, 77), ["puffin"]); + } + + #[test] + fn keywords_map_across_devices_by_content_hash_where_there_is_no_server() { + // A local-only library has no `remote` rows at all, so the hash is the + // only identity available — and it is the one membership already uses. + let c = two_catalogs(); + add_image(&c, "main", 77, "same-photo"); + add_image(&c, "remote_cat", 3, "same-photo"); + add_version(&c, "main", 77, "v-main"); + keyword(&c, "remote_cat", 3, "puffin"); + + merge_keywords(&c).unwrap(); + assert_eq!(words_on(&c, 77), ["puffin"]); + } + + #[test] + fn the_vocabulary_merges_by_uuid_and_a_skewed_clock_cannot_win() { + let c = two_catalogs(); + add_term(&c, "main", "u-1", "Iceland", 9, 0); + add_term(&c, "remote_cat", "u-1", "iceland", 2, 0); + add_term(&c, "remote_cat", "u-2", "puffin", 1, 0); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_kept_local, 1); + assert_eq!(report.keywords_inserted, 1); + assert_eq!(live_terms(&c), ["Iceland", "puffin"]); + } + + #[test] + fn a_remote_rename_moves_this_device_s_assignments_too() { + // The failure this exists to stop: the vocabulary shows the corrected + // spelling and the search still only finds the old one. + let c = two_catalogs(); + add_image(&c, "main", 1, "hash-a"); + add_term(&c, "main", "u-1", "Icland", 1, 0); + keyword(&c, "main", 1, "Icland"); + add_term(&c, "remote_cat", "u-1", "Iceland", 4, 0); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_updated, 1); + assert_eq!(live_terms(&c), ["Iceland"]); + assert_eq!(words_on(&c, 1), ["Iceland"]); + } + + #[test] + fn a_remote_deletion_takes_the_word_off_every_photograph() { + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + add_term(&c, "main", "u-1", "blurry", 1, 0); + keyword(&c, "main", 1, "blurry"); + add_term(&c, "remote_cat", "u-1", "blurry", 5, 1); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_deleted, 1); + assert!(live_terms(&c).is_empty()); + assert!(words_on(&c, 1).is_empty()); + } + + #[test] + fn a_deletion_is_not_undone_by_the_union_on_the_same_pass() { + // The remote deleted the word *and* still carries assignments for it — + // it has not yet had the chance to sweep them, or a third device put + // them there. Without the tombstone filter the union would put the word + // straight back on the photograph the deletion had just cleared. + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + add_term(&c, "main", "u-1", "blurry", 1, 0); + keyword(&c, "main", 1, "blurry"); + add_term(&c, "remote_cat", "u-1", "blurry", 5, 1); + keyword(&c, "remote_cat", 1, "blurry"); + + merge_keywords(&c).unwrap(); + assert!(words_on(&c, 1).is_empty(), "a deleted keyword came back"); + } + + #[test] + fn a_deletion_lands_even_when_the_two_devices_minted_different_uuids() { + // Both typed "blurry" before they ever synced, so this device's row has + // a uuid the remote has never heard of. Deleting by identity would + // tombstone nothing and leave every photograph still carrying the word. + let c = two_catalogs(); + add_image(&c, "main", 1, "hash-a"); + add_term(&c, "main", "mine", "blurry", 1, 0); + keyword(&c, "main", 1, "blurry"); + add_term(&c, "remote_cat", "theirs", "blurry", 5, 1); + + merge_keywords(&c).unwrap(); + assert!(words_on(&c, 1).is_empty()); + } + + #[test] + fn two_devices_that_typed_one_word_end_up_with_one_keyword() { + // Neither is wrong until they meet, which is why the name carries no + // unique index — a constraint would abort the merge at this moment. + let c = two_catalogs(); + add_term(&c, "main", "zzzz", "Iceland", 3, 0); + add_term(&c, "remote_cat", "aaaa", "Iceland", 1, 0); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_fused, 1); + assert_eq!(live_terms(&c), ["Iceland"]); + + let survivor: String = c + .query_row("SELECT uuid FROM main.keyword_terms", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + survivor, "aaaa", + "both devices must pick the same survivor without asking each other" + ); + } + + #[test] + fn merging_keywords_twice_changes_nothing_the_second_time() { + let c = two_catalogs(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + add_term(&c, "remote_cat", "u-1", "puffin", 1, 0); + keyword(&c, "remote_cat", 1, "puffin"); + + let first = merge_keywords(&c).unwrap(); + assert!(first.local_changed()); + let second = merge_keywords(&c).unwrap(); + assert!(!second.local_changed(), "merge must be idempotent"); + } + + #[test] + fn a_remote_from_before_keyword_identities_still_contributes_its_words() { + // `remote_is_mergeable` admits an older remote on purpose — the check + // is that it is not *newer* than us. A missing table is therefore a + // normal state and must not fail the merge. + let c = two_catalogs_with_a_v1_remote(); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + keyword(&c, "remote_cat", 1, "puffin"); + + let report = merge_keywords(&c).unwrap(); + assert_eq!(report.keywords_assigned, 1); + assert_eq!(words_on(&c, 1), ["puffin"]); + } + + #[test] + fn merge_all_lands_both_halves() { + let c = two_catalogs(); + add_collection(&c, "remote_cat", 1, "u-coll", "Portugal", 1); + for db in ["main", "remote_cat"] { + add_image(&c, db, 1, "hash-a"); + } + keyword(&c, "remote_cat", 1, "puffin"); + + let report = merge_all(&c).unwrap(); + assert_eq!(report.inserted, 1); + assert_eq!(report.keywords_assigned, 1); + } + #[test] fn keeping_local_still_marks_the_catalog_for_upload() { // We hold something the remote does not, so the remote is stale even diff --git a/core/dr-catalog/src/rating.rs b/core/dr-catalog/src/rating.rs index 1c90595..139acd9 100644 --- a/core/dr-catalog/src/rating.rs +++ b/core/dr-catalog/src/rating.rs @@ -75,6 +75,24 @@ impl Judgement { /// The UUID is per row and generated here — it is the merge identity across /// devices (FR-NC-8), so two images must never share one. pub fn ensure_default_versions(conn: &Connection) -> Result { + // One transaction for the batch. A backfill over a 24k-image library is + // 24k inserts, and per-statement commits would make it minutes rather + // than seconds. + let tx = conn.unchecked_transaction()?; + let n = ensure_default_versions_within(&tx)?; + tx.commit()?; + Ok(n) +} + +/// [`ensure_default_versions`] without opening a transaction. +/// +/// Separate because SQLite has no nested `BEGIN`: [`crate::merge`] needs the +/// invariant restored *inside* the merge transaction — an incoming keyword +/// lands on a default version, so an image without one would silently drop it — +/// and calling the public form there fails at runtime with "cannot start a +/// transaction within a transaction". The same split, for the same reason, as +/// `collections::add_within`. +pub fn ensure_default_versions_within(conn: &Connection) -> Result { let ids: Vec = { let mut stmt = conn.prepare( "SELECT i.id FROM images i @@ -89,12 +107,8 @@ pub fn ensure_default_versions(conn: &Connection) -> Result return Ok(0); } - // One transaction for the batch. A backfill over a 24k-image library is - // 24k inserts, and per-statement commits would make it minutes rather - // than seconds. - let tx = conn.unchecked_transaction()?; { - let mut insert = tx.prepare( + let mut insert = conn.prepare( "INSERT INTO versions(image_id, uuid, name, is_default, rating, flag) VALUES (?1, ?2, ?3, 1, 0, 0)", )?; @@ -102,7 +116,6 @@ pub fn ensure_default_versions(conn: &Connection) -> Result insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?; } } - tx.commit()?; Ok(ids.len()) } diff --git a/core/dr-catalog/src/sync.rs b/core/dr-catalog/src/sync.rs index 7cabf36..01d302d 100644 --- a/core/dr-catalog/src/sync.rs +++ b/core/dr-catalog/src/sync.rs @@ -16,10 +16,11 @@ //! //! # What is actually synced //! -//! Only collections merge (see [`crate::merge`]). The rest of the catalog is a -//! *local index* of *local* storage — folder mtimes, cache paths, job rows — -//! and copying another device's version of those in would be actively wrong. -//! The remote file is read for its collections and then discarded. +//! Only the *user's judgements about their library* merge: collections, and the +//! keyword vocabulary with its assignments (see [`crate::merge`]). The rest of +//! the catalog is a *local index* of *local* storage — folder mtimes, cache +//! paths, job rows — and copying another device's version of those in would be +//! actively wrong. The remote file is read for those two and then discarded. //! //! This is why the catalog remains disposable in the ARCH §6.12 sense: nothing //! here makes the local database authoritative for anything a rebuild could @@ -96,7 +97,7 @@ pub fn merge_remote(conn: &Connection, remote: &Path) -> Result Date: Sat, 22 Aug 2026 15:51:11 +0200 Subject: [PATCH 03/27] Give the library somewhere to type a keyword MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sheet over the grid, opened from the header beside "Add to collection" — deliberately the same card, scrim and dismissal as the filing sheet, because they are the same gesture applied to two kinds of label: pick the photographs, then say what they are. A user who has filed a selection already knows how this works. A word the whole selection carries, a word only some of it carries, and a word none of it carries are three visibly different marks. Half-applied shown as applied would be a lie about photographs the user cannot see from here, so a partial keyword draws a dash and says "3 of 12" beside it. Tapping a dash completes the keyword rather than removing it, which is what it means nine times in ten, and the tenth is one more tap away. The vocabulary is answered against the selection in Rust and pulled when the sheet opens rather than pushed on every selection change — the selection moves on each arrow key and the sheet is shut for almost all of them. Assign and unassign travel by name, so a word typed into the field and a word tapped in the list are one path rather than two, and the sheet never has to invent an identity for a keyword that does not exist yet. One gap, commented at the call site: unlike a star or a flag, a keyword is not queued to the image's sidecar, because the sidecar format has no field for one. So it reaches the user's other devices through the catalog merge, and a deleted catalog loses keywords where it would keep ratings. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/src/library_ui.rs | 265 +++++++++++++++++++++++++++++++++++- ui/dr-ui/ui/app.slint | 25 +++- ui/dr-ui/ui/library.slint | 270 ++++++++++++++++++++++++++++++++++++- 3 files changed, 557 insertions(+), 3 deletions(-) diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index 38a6451..e8b2dc8 100644 --- a/ui/dr-ui/src/library_ui.rs +++ b/ui/dr-ui/src/library_ui.rs @@ -22,7 +22,7 @@ use dr_types::FormatFilter; use slint::{ComponentHandle, Model as _}; use crate::library::{self, ScanMessage, ThumbnailMessage}; -use crate::{AppWindow, LibraryCell, TimelineBar}; +use crate::{AppWindow, KeywordRow, LibraryCell, TimelineBar}; /// Window size before the grid has reported its geometry. /// @@ -2117,6 +2117,170 @@ fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) { window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32); } +// --- keywords (FR-CAT-5, FR-CAT-6) --------------------------------------- +// +// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many +// join, what a rename does to the assignments. This part owns the *interaction*: +// which photographs the sheet is acting on, and keeping what it draws honest +// about what actually landed. + +/// Redraw the keywording sheet against whatever is selected now. +/// +/// Called when the sheet opens and after every assignment, rather than on every +/// selection change: the selection moves on each arrow key and the sheet is shut +/// for almost all of them, so computing coverage over a forty-image selection +/// on each one would be work nobody is looking at. +/// +/// Re-read from the catalog rather than patched in place after a write. A word +/// applied to a selection that partly already had it moves from "3 of 12" to +/// "12 of 12", and a model updated by hand would have to reproduce the rule +/// that decides that — which is exactly the rule the catalog has just applied. +fn refresh_keywords(window: &AppWindow, ctl: &Rc, images: &[dr_types::ImageId]) { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + + let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) { + Ok(rows) => rows, + Err(e) => { + // The grid is entirely usable without the sheet, so this is logged + // rather than surfaced: a keyword read that failed must not put an + // error banner over a library the user is browsing. + log::debug!("reading keywords: {e}"); + return; + } + }; + + let model: Vec = rows + .into_iter() + .map(|row| KeywordRow { + id: row.keyword.id.0 as i32, + name: row.keyword.name.into(), + coverage: match row.coverage { + dr_catalog::Coverage::None => 0, + dr_catalog::Coverage::Some => 1, + dr_catalog::Coverage::All => 2, + }, + selected_count: row.selected_count as i32, + image_count: row.keyword.image_count as i32, + }) + .collect(); + + window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model))); +} + +/// Put a keyword on the selection, or take it off. +/// +/// # Why this does not write a sidecar +/// +/// Every other judgement in this file — a star, a flag — is written to the +/// catalog and then queued to the image's sidecar, because the sidecar is what +/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in +/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and +/// `flag` and nothing else that is not an edit-graph parameter. +/// +/// So a keyword is, for now, catalog state that reaches the user's other +/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than +/// through the sidecar. That is a real limitation and not a silent one: a +/// deleted catalog loses keywords where it would keep ratings, until the +/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same +/// queued write the stars have. +fn apply_keyword(window: &AppWindow, ctl: &Rc, word: &str, assigning: bool) { + let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else { + return; + }; + let images = coll.selected(); + + // The word as it will be *stored*, resolved before anything is written. + // The status line below quotes it back, and quoting what was typed would + // report a leading space the catalog is about to drop — leaving the user to + // wonder whether it mattered. + // + // This is also where a blank keyword is caught, which is why it happens + // before the selection check: "you typed nothing" is a better answer than + // "select an image first" to someone who pressed return on an empty field. + let word = match dr_catalog::keywords::normalise(word) { + Ok(word) => word, + Err(e) => { + // `BadName` carries text written to be read by the user rather than + // by a developer, so it is shown as it is. + window.set_library_error(format!("{e}").into()); + return; + } + }; + + // Assigning with nothing selected still means something — it puts the word + // in the vocabulary, ready for the photographs it was typed for — so only + // the removal half needs a selection to act on. + if images.is_empty() && !assigning { + window.set_library_status("Select an image first".into()); + return; + } + + let outcome = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let conn = catalog.connection(); + if assigning { + dr_catalog::keywords::assign(conn, &images, &word) + } else { + dr_catalog::keywords::unassign(conn, &images, &word) + } + }; + + let n = match outcome { + Ok(n) => n, + Err(e) => { + window.set_library_error(format!("{e}").into()); + return; + } + }; + + window.set_library_error(slint::SharedString::new()); + window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into()); + refresh_keywords(window, ctl, &images); + + // A filtered grid may no longer hold what was just keyworded — taking + // "puffin" off an image while showing only puffins means it belongs + // elsewhere now. The same reasoning as a rating that falls below the star + // filter. + if !ctl.filter.borrow().is_unfiltered() { + load_window(window, ctl); + } +} + +/// What the status line says about a keyword that just landed. +/// +/// The honest count, not the requested one: "added to 3 of 12" is what +/// happened when nine of them already carried the word, and a message that +/// claimed twelve would be teaching the user that the counts are decorative. +fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String { + if selected == 0 { + return format!("Added “{word}” to the keyword list"); + } + let verb = if assigning { "Added" } else { "Removed" }; + let preposition = if assigning { "to" } else { "from" }; + if changed == 0 { + return if assigning { + format!("Every selected photograph already had “{word}”") + } else { + format!("None of the selected photographs had “{word}”") + }; + } + if changed == selected { + let what = if selected == 1 { + "1 photograph".to_string() + } else { + format!("{selected} photographs") + }; + return format!("{verb} “{word}” {preposition} {what}"); + } + format!("{verb} “{word}” {preposition} {changed} of {selected}") +} + /// Apply a judgement to a set of images: catalog first, then sidecars. /// /// # Order matters @@ -4152,6 +4316,40 @@ pub fn wire( }); } + // --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------ + // + // Three callbacks and no state of their own: the sheet's open/shut is local + // to the `.slint` file, and what a keyword applies to is the grid selection + // the collections controller already owns. A second copy of either here is + // a second thing that can disagree with the first. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_for_keywords = coll_ctl.clone(); + window.on_library_keywords_opened(move || { + let Some(w) = weak.upgrade() else { return }; + refresh_keywords(&w, &ctl, &coll_for_keywords.selected()); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_assign_keyword(move |word| { + let Some(w) = weak.upgrade() else { return }; + apply_keyword(&w, &ctl, word.as_str(), true); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_unassign_keyword(move |word| { + let Some(w) = weak.upgrade() else { return }; + apply_keyword(&w, &ctl, word.as_str(), false); + }); + } + // --- the filter bar --------------------------------------------------- // // Each of these narrows what the grid *queries*, so all three reset the @@ -5017,6 +5215,71 @@ mod tests { assert_eq!(paths, vec!["c.CR2", "a.CR2"]); } + // --- what the status line says about a keyword (FR-CAT-5) ------------- + // + // Split out from the callback for the same reason `decide_drop` is: the + // sheet cannot be driven from a test, and this is the part that can + // actually mislead someone. + + /// TRACES: FR-CAT-5 + #[test] + fn a_partly_applied_keyword_reports_the_honest_count() { + // Nine of the twelve already had it. Claiming twelve is how a user + // learns that the counts are decorative. + assert_eq!( + keyword_summary("puffin", 3, 12, true), + "Added “puffin” to 3 of 12" + ); + } + + /// TRACES: FR-CAT-5 + #[test] + fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() { + assert_eq!( + keyword_summary("puffin", 0, 12, true), + "Every selected photograph already had “puffin”" + ); + assert_eq!( + keyword_summary("puffin", 0, 12, false), + "None of the selected photographs had “puffin”" + ); + } + + /// TRACES: FR-CAT-5 + #[test] + fn one_photograph_is_singular() { + // "Added to 1 photographs" is the kind of small wrongness that makes + // the rest of the interface look unfinished. + assert_eq!( + keyword_summary("puffin", 1, 1, true), + "Added “puffin” to 1 photograph" + ); + assert_eq!( + keyword_summary("puffin", 2, 2, true), + "Added “puffin” to 2 photographs" + ); + } + + /// TRACES: FR-CAT-5 + #[test] + fn removing_a_keyword_reads_as_removal() { + assert_eq!( + keyword_summary("blurry", 4, 4, false), + "Removed “blurry” from 4 photographs" + ); + } + + /// TRACES: FR-CAT-5 + #[test] + fn typing_a_word_with_nothing_selected_says_what_it_did_do() { + // It builds the vocabulary, which is a legitimate thing to do ahead of + // a shoot — so it must not report itself as having keyworded nothing. + assert_eq!( + keyword_summary("puffin", 0, 0, true), + "Added “puffin” to the keyword list" + ); + } + /// TRACES: FR-EXP-7 #[test] fn a_selection_outside_the_loaded_window_still_resolves() { diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 8460f8e..fff578a 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -2,7 +2,7 @@ import { Theme } from "theme.slint"; import { AdjustPanel, GeometryPanel, GroupStrip, ParamRow, TransferPanel } from "adjust.slint"; import { MaskPanel, MaskRow, SubjectRow } from "masks.slint"; import { LaunchScreen } from "launch.slint"; -import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll } from "library.slint"; +import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow } from "library.slint"; import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint"; import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint"; import { HistogramPanel, HistogramView } from "histogram.slint"; @@ -572,6 +572,25 @@ export component AppWindow inherits Window { /// the target's id, and whether to take the images out of the collection /// currently being shown. callback library-file-in-collection(int, bool); + /// TRACES: FR-CAT-5 | FR-CAT-6 + /// Keywording the grid's selection. The catalog has been searchable by + /// keyword since it existed and there was nowhere to type one; this is it. + /// + /// The vocabulary arrives already answered against the selection — each row + /// says how many of the selected photographs carry that word — because only + /// Rust knows what is selected, and a `.slint` file counting it would need + /// the selection as a second model that could disagree with the first. + in property <[KeywordRow]> library-keywords; + /// The sheet is opening: recompute the rows against the selection as it + /// stands now. Pulled rather than pushed, because the selection changes on + /// every arrow key and the sheet is shut for almost all of them. + callback library-keywords-opened(); + /// Put a keyword on the selection, creating it if it is new. By name, so a + /// word typed into the field and a word tapped in the list are one path. + callback library-assign-keyword(string); + /// Take a keyword off the selection. Never deletes the keyword itself — + /// it stays in the vocabulary and on every other photograph that carries it. + callback library-unassign-keyword(string); /// TRACES: FR-UI-2 /// Whether a tap in the grid selects rather than opens, and the button /// that turns it on. The long press does the same thing without it. @@ -1229,6 +1248,10 @@ in property panel-visible: true; file-in-collection(id, moves) => { root.library-file-in-collection(id, moves); } + keywords: root.library-keywords; + keywords-opened() => { root.library-keywords-opened(); } + assign-keyword(word) => { root.library-assign-keyword(word); } + unassign-keyword(word) => { root.library-unassign-keyword(word); } cursor: root.library-cursor; move-cursor(delta, extend) => { root.library-move-cursor(delta, extend); diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 5089a45..4e1a50f 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -9,12 +9,38 @@ // must not look identical (FR-NC-6c). import { Theme } from "theme.slint"; -import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon } from "widgets.slint"; +import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint"; // The filing sheet lists the same rows the sidebar draws, from the same model: // two lists of collections that could disagree about what exists is one list // too many. import { CollectionRow } from "collections.slint"; +// TRACES: FR-CAT-5 +// One keyword in the keywording sheet, already answered against the selection. +// +// The three-way `coverage` is the whole reason this is a struct rather than a +// list of strings. Applying a word to forty photographs where thirty already +// carry it must not look like applying it to forty that carry none, and +// removing one that only some of them carry must not silently claim to have +// taken it off all forty. Rust computes it, because only Rust knows how big the +// selection is and how many of it each word covers. +export struct KeywordRow { + // Row id in `keyword_terms`, or 0 for a word an image carries that the + // vocabulary has no identity for yet. The sheet acts on `name`, never on + // this, so a 0 costs nothing — it is here so a future rename gesture has + // something to name. + id: int, + name: string, + // 0 none of the selection, 1 some of it, 2 all of it. + coverage: int, + // How many of the selected photographs carry it, for the "3 of 12" that + // makes `coverage: 1` a number rather than a shrug. + selected-count: int, + // How many photographs in the whole library carry it. Lets a word in + // regular use be told from one typed once by mistake. + image-count: int, +} + // One bar of the capture-time histogram. export struct TimelineBar { // 0..1, relative to the tallest bucket. Square-rooted in Rust so a quiet @@ -656,6 +682,9 @@ component HeaderActions inherits HorizontalLayout { callback remove-from-collection(); /// Open the sheet that files the selection in a collection. callback add-to-collection(); + /// TRACES: FR-CAT-5 + /// Open the sheet that keywords the selection. + callback add-keyword(); callback toggle-select-mode(); callback change-library(); callback toggle-pin-scope(); @@ -694,6 +723,17 @@ component HeaderActions inherits HorizontalLayout { clicked => { root.add-to-collection(); } } + // TRACES: FR-CAT-5 | FR-CAT-6 + // Keyword the selection. Beside "Add to collection" because they are the + // same thought — these photographs are *of* something, and they belong + // *with* something — and appearing under the same condition, because + // neither means anything without a selection to act on. + if root.selected-count > 0: Button { + text: "Keywords"; + y: root.centred ? (root.row-height - self.height) / 2 : 0; + clicked => { root.add-keyword(); } + } + // TRACES: FR-DEV-6 // Batch-apply the copied settings. Shown only with both a selection and a // clipboard, because it is meaningless without either — and because a @@ -1105,6 +1145,34 @@ export component LibraryGrid inherits Rectangle { /// out of the one currently being shown. callback file-in-collection(int, bool); + // --- keywording the selection (FR-CAT-5, FR-CAT-6) ---------------------- + // + // The catalog has been searchable by keyword since it existed and there was + // never anywhere to type one. This sheet is that place, and it sits beside + // the filing sheet above because the two are the same gesture applied to + // two different kinds of label — pick the photographs, then say what they + // are — and a user who has learnt one should not have to learn the other. + // + // Assign and unassign travel by **name**, not by id. A word typed into the + // field and a word tapped in the list are then one path through Rust rather + // than two, and the sheet does not have to invent an id for a keyword that + // does not exist yet. + /// The vocabulary, already answered against the current selection. + in property <[KeywordRow]> keywords; + /// The sheet is opening: Rust answers by refreshing `keywords` against + /// whatever is selected *now*. + /// + /// Pulled on open rather than pushed on every selection change, because the + /// selection changes on every arrow key and the sheet is shut for almost + /// all of them — recomputing coverage over a forty-image selection for a + /// panel nobody is looking at is work the grid cannot afford. + callback keywords-opened(); + callback assign-keyword(string); + callback unassign-keyword(string); + /// Whether the sheet is up. Local, for the same reason `filing` is: it is a + /// disclosure rather than a preference, and what closes it is dismissing it. + property keywording: false; + // Cell geometry. Columns are derived from the available width so the grid // reflows with the window rather than fixing a count (FR-UI-1). // Zoomable, so the grid serves both jobs: fewer, larger images for @@ -1355,6 +1423,14 @@ export component LibraryGrid inherits Rectangle { // to is still there when the sheet closes. root.actions-open = false; } + add-keyword => { + // Ask for the vocabulary before showing the sheet, so + // it is answered against the selection as it stands now + // rather than as it stood when the grid last loaded. + root.keywords-opened(); + root.keywording = true; + root.actions-open = false; + } change-library => { root.change-library(); } toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } @@ -1429,6 +1505,14 @@ export component LibraryGrid inherits Rectangle { // to is still there when the sheet closes. root.actions-open = false; } + add-keyword => { + // Ask for the vocabulary before showing the sheet, so + // it is answered against the selection as it stands now + // rather than as it stood when the grid last loaded. + root.keywords-opened(); + root.keywording = true; + root.actions-open = false; + } change-library => { root.change-library(); } toggle-pin-scope => { root.toggle-pin-scope(); } sync-now => { root.sync-now(); } @@ -1788,6 +1872,10 @@ export component LibraryGrid inherits Rectangle { // button, and a sheet it walked straight past would leave // the user out of the grid with their selection gone. if (event.text == Key.Back || event.text == Key.Escape) { + if (root.keywording) { + root.keywording = false; + return accept; + } if (root.filing) { root.filing = false; return accept; @@ -2486,4 +2574,184 @@ export component LibraryGrid inherits Rectangle { } } } + + // --- the keywording sheet (FR-CAT-5, FR-CAT-6) -------------------------- + // + // "These are of…". Deliberately the same card, scrim and dismissal as the + // filing sheet above: a user who has filed a selection already knows how + // this works, and a second idiom for the same gesture would be a second + // thing to learn for no gain. + // + // It stays open after each word, where the filing sheet closes. Filing is + // one choice; keywording is usually several — "puffin", "Látrabjarg", + // "2026" — and a sheet that shut after each one would have to be reopened, + // and the selection re-confirmed, three times over. + if root.keywording: Rectangle { + background: #000000CC; + + // Swallows the taps that miss the card, and closes. First, so the + // card's own controls sit above it. + TouchArea { + clicked => { root.keywording = false; } + } + + Rectangle { + width: min(420px, parent.width - 2 * Theme.gap-lg); + height: min(kw-sheet.preferred-height, parent.height - 2 * Theme.gap-lg); + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.surface; + border-radius: Theme.radius; + border-width: 1px; + border-color: Theme.rule; + + // Stops a press on the card reaching the scrim behind it. + TouchArea { } + + kw-sheet := VerticalLayout { + padding: Theme.gap-lg; + spacing: Theme.gap; + + Text { + text: root.selected-count == 1 + ? "Keywords for 1 photograph" + : "Keywords for " + root.selected-count + " photographs"; + color: Theme.ink; + font-size: Theme.text-lg; + font-weight: 600; + wrap: word-wrap; + } + + // Typing a word applies it, whether or not it already exists. + // One field for both, because "is this keyword new?" is a + // question about the catalog and not about what the user meant, + // and Rust can answer it without being asked. + // + // The field clears itself on accept so the next word can be + // typed straight after — keywording a shoot is a run of them. + new-keyword := Field { + placeholder: "Type a keyword and press return"; + accepted(text) => { + root.assign-keyword(text); + self.text = ""; + } + } + + Rectangle { height: 1px; background: Theme.rule; } + + Flickable { + vertical-stretch: 1; + // A floor, so the list is not squeezed out of existence by + // the field and the button around it on a short window. + min-height: 120px; + viewport-height: root.keywords.length * (Theme.touch-target + 2px); + + for word[i] in root.keywords: Rectangle { + y: i * (Theme.touch-target + 2px); + width: parent.width; + // A full touch target per row, for the same reason the + // filing sheet uses one: this is a place to hit once, + // with a thumb, holding a selection that took a minute + // to build (FR-UI-3). + height: Theme.touch-target; + background: kw-touch.pressed ? Theme.pressed + : (kw-touch.has-hover ? Theme.hover : transparent); + border-radius: Theme.radius-sm; + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + // Tick, dash, or nothing — the three states of + // `coverage`, drawn as three different marks rather + // than as two. A half-applied keyword shown as + // applied is a lie about photographs the user + // cannot see from here. + Rectangle { + width: 16px; + y: (parent.height - self.height) / 2; + height: 16px; + border-radius: Theme.radius-sm; + border-width: 1px; + border-color: word.coverage == 0 ? Theme.rule : Theme.active; + background: word.coverage == 2 ? Theme.active : transparent; + + // The dash for "some of them". A bar rather + // than a tick, because a tick at half strength + // reads as a rendering artefact. + if word.coverage == 1: Rectangle { + width: 8px; + height: 2px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + background: Theme.active; + } + if word.coverage == 2: Icon { + name: "check"; + ink: Theme.surface; + size: 12px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + } + } + + Text { + text: word.name; + color: Theme.ink; + font-size: Theme.text; + vertical-alignment: center; + overflow: elide; + horizontal-stretch: 1; + } + + // "3 of 12" only where it says something the mark + // does not. For a word the whole selection carries, + // or none of it, the mark has already said it and + // the number would be noise on every row. + Text { + text: word.coverage == 1 + ? word.selected-count + " of " + root.selected-count + : (word.image-count > 0 ? word.image-count + "" : ""); + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + } + + // One target for both directions. A word the selection + // fully carries comes off; anything else goes on — so a + // partly-applied keyword is completed rather than + // removed, which is what a user tapping a dash means + // nine times in ten, and the tenth is one more tap + // away. + kw-touch := TouchArea { + clicked => { + if (word.coverage == 2) { + root.unassign-keyword(word.name); + } else { + root.assign-keyword(word.name); + } + } + } + } + + if root.keywords.length == 0: Text { + text: "No keywords yet. Type one above to make the first."; + color: Theme.ink-faint; + font-size: Theme.text-sm; + wrap: word-wrap; + width: parent.width; + } + } + + Rectangle { height: 1px; background: Theme.rule; } + + Button { + text: "Done"; + clicked => { root.keywording = false; } + } + } + } + } } From d64a61d6774ccf71929a212bcda09772c07fafa9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 16:06:13 +0200 Subject: [PATCH 04/27] Give the tone curve a curve for each colour channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration in ops/tone_curve.yaml has claimed per-channel curves since it was written — it is the justification for the operation carrying both `tone` and `colour`. Only the master curve existed. This is the other three. The master runs first and the channels grade its result. Both orders are real images and they differ visibly, so the choice is made and written down rather than left to the loop: a point placed on the blue curve should act on the tone the photographer can see, which is what the master has already produced. The other order anchors the grade to tones the master is about to move, so adjusting contrast slides a warm shadow up into the midtones. Every id that existed before today is spelled exactly as it was. The master curve keeps `p2_y` and the new curves take `r_`, `g_` and `b_` prefixes, so a sidecar written when there was one curve loads, means what it meant, and renders the same shader — asserted on the generated source, not on the parameter values. Nothing needed a version check because nothing was renamed. Each curve reaches the shader only when it has been moved off the diagonal, so an S-curve and no colour work generates what it generated when this operation held ten parameters instead of forty, down to the uniform names. The monotonicity guarantee is enforced per curve: a coincident pair on blue divides by zero exactly as thoroughly as one on the master. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/tests/tone_curve.rs | 139 ++++ core/dr-pipeline/ops/README.md | 7 +- core/dr-pipeline/ops/tone_curve.yaml | 23 +- core/dr-pipeline/src/ops/curve.rs | 1056 ++++++++++++++++++++++---- core/dr-pipeline/src/sidecar.rs | 86 ++- core/dr-pipeline/tests/tone_curve.rs | 202 +++++ 6 files changed, 1339 insertions(+), 174 deletions(-) create mode 100644 core/dr-gpu/tests/tone_curve.rs create mode 100644 core/dr-pipeline/tests/tone_curve.rs diff --git a/core/dr-gpu/tests/tone_curve.rs b/core/dr-gpu/tests/tone_curve.rs new file mode 100644 index 0000000..b12eea0 --- /dev/null +++ b/core/dr-gpu/tests/tone_curve.rs @@ -0,0 +1,139 @@ +//! TRACES: FR-DEV-3 +//! The tone curve's four curves, on a device. +//! +//! `dr-pipeline` asserts that the right WGSL is generated and `dr-gpu`'s other +//! tests assert that a shader runs; neither notices a fragment that says +//! exactly what it should and does not compile, or one that compiles and puts +//! the red curve's uniforms into the blue slot. So this renders flat grey +//! through each curve and looks at what came out. +//! +//! Flat grey because it makes every assertion a comparison between the three +//! components of one pixel: a curve that is meant to be chromatic must move +//! them apart, and one that is meant to be tonal must not. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::ops::curve::{self, Axis, Channel}; +use dr_pipeline::EditGraph; + +const SIZE: u32 = 8; + +fn ctx() -> Option { + pollster::block_on(GpuContext::new_headless()).ok() +} + +/// The centre pixel's red, green and blue, after `graph` has run over flat +/// mid-grey. +fn rendered(ctx: &GpuContext, graph: &EditGraph) -> (u8, u8, u8) { + let data: Vec = (0..SIZE * SIZE).flat_map(|_| [128, 128, 128, 255]).collect(); + let source = DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload"); + + // Composed the way the display path composes it. A curve that generates + // invalid WGSL fails at `render` below, which is the point of running this + // on a device at all. + let shader = graph.compose(); + + let mut adjust = AdjustPass::new(ctx); + adjust.render(&source, &shader, SIZE, SIZE).expect("render"); + let pixels = adjust.export_pixels().expect("readback").0; + + let at = ((SIZE / 2 * SIZE + SIZE / 2) * 4) as usize; + (pixels[at], pixels[at + 1], pixels[at + 2]) +} + +/// A curve with its mid-point lifted — the simplest edit that is unmistakably +/// an edit. +fn lifted(channel: Channel) -> EditGraph { + let mut graph = EditGraph::default_chain(); + graph.set_param( + curve::ID, + curve::coordinate(channel, 2, Axis::Y), + 0.75, + ); + graph +} + +#[test] +fn the_master_curve_lifts_every_component_together() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain()); + let (r, g, b) = rendered(&ctx, &lifted(Channel::Master)); + + assert!(r > r0, "the master curve did not lift the image: {r} vs {r0}"); + // Grey in, grey out: the master curve is applied as a ratio over + // luminance, so it changes tone and not hue. A tolerance of one code + // value, because the components travel through the ratio separately and + // the result is quantised to eight bits. + assert!( + r.abs_diff(g) <= 1 && g.abs_diff(b) <= 1, + "the master curve tinted a neutral pixel: {r},{g},{b}" + ); + assert_eq!((g0, b0), (r0, r0), "the unedited image is neutral"); +} + +#[test] +fn a_channel_curve_lifts_only_its_own_component() { + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let (r0, g0, b0) = rendered(&ctx, &EditGraph::default_chain()); + for (channel, name) in [ + (Channel::Red, "red"), + (Channel::Green, "green"), + (Channel::Blue, "blue"), + ] { + let (r, g, b) = rendered(&ctx, &lifted(channel)); + // The component the curve names moves; the other two stay exactly + // where they were. This is what catches a fragment whose uniforms are + // wired to the wrong curve — it would still lift *something*. + let (moved, still) = match channel { + Channel::Red => (r > r0, g == g0 && b == b0), + Channel::Green => (g > g0, r == r0 && b == b0), + _ => (b > b0, r == r0 && g == g0), + }; + assert!(moved, "the {name} curve changed nothing: {r},{g},{b}"); + assert!( + still, + "the {name} curve moved a component that was not its own: \ + {r},{g},{b} from {r0},{g0},{b0}" + ); + } +} + +#[test] +fn the_master_and_the_channels_compose_in_one_pass() { + // All four curves at once: the case where the generated fragment is + // longest, every helper is present, and forty uniforms are in the block. + // Mostly a compile check, which is why the assertion is only that the + // result is a colour and not the one we started with. + let Some(ctx) = ctx() else { + eprintln!("no adapter; skipping"); + return; + }; + + let mut graph = EditGraph::default_chain(); + graph.set_param(curve::ID, curve::P1_Y, 0.15); + graph.set_param(curve::ID, curve::P3_Y, 0.85); + for (channel, y) in [ + (Channel::Red, 0.55), + (Channel::Green, 0.5), + (Channel::Blue, 0.62), + ] { + graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), y); + } + + let (r0, _, _) = rendered(&ctx, &EditGraph::default_chain()); + let (r, g, b) = rendered(&ctx, &graph); + assert!( + (r, g, b) != (r0, r0, r0), + "four active curves left the image untouched" + ); + // Red and blue were pushed apart from green, which is the chromatic half + // doing its work on top of the tonal one. + assert!(b > g, "blue was lifted above green: {r},{g},{b}"); +} diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index 29344fa..0fe79c9 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -209,9 +209,10 @@ They still belong in this directory, because the pipeline's **order** is the one thing a reader comes here to learn, and an order written half in YAML and half in Rust would be worse than either alone. -Currently hand-written: `tone_curve` (a curve widget over five interpolated -points), `colour_mixer` (thirty-six faceted parameters from twelve computed hue -bands). `vignetting` is hand-written too but is not in the develop chain — it +Currently hand-written: `tone_curve` (one widget over four curves of five +interpolated points — master, red, green, blue — each reaching the shader only +when it has been moved), `colour_mixer` (thirty-six faceted parameters from +twelve computed hue bands). `vignetting` is hand-written too but is not in the develop chain — it carries lens-profile coefficients that are not parameters. `distortion` and `aberration` are `Warp`s rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it. diff --git a/core/dr-pipeline/ops/tone_curve.yaml b/core/dr-pipeline/ops/tone_curve.yaml index 91f9e3b..8f14477 100644 --- a/core/dr-pipeline/ops/tone_curve.yaml +++ b/core/dr-pipeline/ops/tone_curve.yaml @@ -16,13 +16,26 @@ attributes: [tone, colour] rust: ToneCurve why_rust: | - Five control points presented as one curve widget, with an interpolator and - a monotonicity guarantee behind it. Its neutral is a *relationship* between - parameters rather than a set of values — the identity diagonal — which is - not something the declarative `active:` rule can express, and its - `presentation()` spans parameters rather than describing one. + Four curves — master, red, green, blue — of five control points each, + presented as one widget, with an interpolator and a monotonicity guarantee + behind them. Its neutral is a *relationship* between parameters rather than + a set of values — the identity diagonal, on every channel — which is not + something the declarative `active:` rule can express; its `presentation()` + spans forty parameters rather than describing one; and its fragment is + *assembled* rather than written, because each curve reaches the shader only + when it has been moved off the diagonal. A declared node's `wgsl:` is one + fixed block of text, which is the right shape for nearly everything here and + the wrong one for a node whose cost has to follow what the photographer + actually touched. placement: | After the fixed-weight region controls, so the curve is the final word on tone: a photographer reaches for it to fix what those controls could not place exactly. + + Within the node, the master curve runs before the per-channel ones. Both + orders are visibly different images and the reasons for this one are written + out in `src/ops/curve.rs`: the master is tonal and hue-preserving, the + per-channel curves are the chromatic grade over the tones it produced, and a + point placed on a channel curve should act on the tone the photographer can + see rather than on the one the master is about to move. diff --git a/core/dr-pipeline/src/ops/curve.rs b/core/dr-pipeline/src/ops/curve.rs index 6bc0bd3..c2c6782 100644 --- a/core/dr-pipeline/src/ops/curve.rs +++ b/core/dr-pipeline/src/ops/curve.rs @@ -1,9 +1,13 @@ -//! The tone curve — a monotonic spline through five movable points. +//! TRACES: FR-DEV-3 +//! The tone curve — four monotonic splines through five movable points each: +//! a master curve over tone, and one per colour channel. //! //! The control every other tonal adjustment is a preset of. Highlights, //! shadows, blacks and whites each shape one region with a fixed weight; the //! curve lets the photographer put the inflection exactly where the image -//! needs it. +//! needs it. The per-channel curves are the same instrument pointed at colour: +//! a lifted blue black point is the faded shadow every film emulation is built +//! out of, and there is no way to ask for it with a saturation slider. //! //! # Why the points are ordinary scalars //! @@ -14,7 +18,7 @@ //! //! - The parameter API stays `f32`-only, so nothing else in the pipeline, //! the graph or the sidecar had to change to accommodate a curve. -//! - A UI that has not implemented the curve widget renders ten sliders and +//! - A UI that has not implemented the curve widget renders forty sliders and //! remains completely functional. //! - Undo, clamping and sidecar serialisation work already, because the //! points are the same kind of thing as every other parameter. @@ -23,28 +27,223 @@ //! need a variable-length value type, which is a much larger change for a //! control that rarely needs more than five. //! +//! [`ParamKind::Scalar`]: crate::descriptor::ParamKind::Scalar +//! //! # Why monotonic //! //! A plain cubic spline through user-placed points overshoots: drag one point //! and the curve can dip *below* its neighbour, which inverts tones locally //! and shows up as a dark halo in a smooth gradient. The Fritsch-Carlson //! filter constrains the tangents so the interpolant is monotone wherever the -//! data is, which is exactly the guarantee a tone curve needs. +//! data is, which is exactly the guarantee a tone curve needs. It is enforced +//! per curve, because "the master's points are in order" says nothing whatever +//! about the blue one's. +//! +//! # Four curves, and the order they run in +//! +//! The master curve runs **first**, and the per-channel curves run on the +//! colour it produced. The two orders are not cosmetically different — an +//! S-curve followed by a lifted blue black point is a visibly different image +//! from the blue lift followed by the S-curve — so the choice has to be made +//! here and stated, rather than left to whichever loop was written first. +//! +//! It is made this way for two reasons. +//! +//! **A control point's x coordinate should mean the tone the photographer can +//! see.** The channel curves are the finishing grade — warm the shadows, cool +//! the highlights — and the tones being graded are the ones on screen, which +//! are the master curve's output. Running the channels first would anchor them +//! to the tones the master is *about to move*: place a warm shadow, then reach +//! for contrast, and the warmth migrates up into the midtones as the master +//! lifts the region the channel curve was pinned to. In this order the master +//! reshapes what reaches the grade, and the grade stays where it was put on +//! the axis the widget draws. +//! +//! **Tone before colour is the order the rest of the chain already runs in.** +//! The master curve is hue-preserving by construction: it curves *luminance* +//! and reapplies the result as a ratio, exactly as contrast does, so it is a +//! tonal operation and nothing else. The per-channel curves deliberately break +//! that ratio — they are the only part of this operation that can change a +//! hue. Putting the chromatic half last keeps this node in step with the chain +//! around it, where the colour mixer is the finishing control and acts on the +//! tones the tonal operations have already settled (`ops/README.md`). +//! +//! # What four curves cost when three of them are untouched +//! +//! Nothing. Each curve is emitted into the fragment and into the uniform block +//! only when it differs from the identity, so the overwhelmingly common edit — +//! an S-curve on the master and no per-channel work at all — generates exactly +//! the shader it generated when this file held one curve, down to the uniform +//! names. An operation whose four curves are all identity is inactive and +//! contributes no code, no uniform and no branch, which is the property the +//! whole composition scheme rests on (ARCH §5.6). -use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit, - WidgetDemand, WidgetKind,}; +use std::fmt::Write as _; + +use crate::descriptor::{ + Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, + Scale, Unit, WidgetDemand, WidgetKind, +}; use crate::operation::{Helper, Operation, Uniform}; use crate::ops::helpers; pub const ID: OpId = OpId("tone_curve"); -/// How many movable points the curve has. +/// How many movable points a curve has. /// /// Five: the two endpoints, a mid-tone, and one either side. Enough for the /// S-curves and shoulder rolls that make up nearly every tonal edit, few /// enough that the shader can evaluate them without a loop over storage. pub const POINTS: usize = 5; +/// TRACES: FR-DEV-3 +/// Which of the four curves a point belongs to. +/// +/// `Master` is the curve that existed before the other three, and it keeps +/// that position in every list here: it is the one a photographer reaches for +/// first, it is the one that runs first, and — see [`Channel::prefix`] — it is +/// the one whose parameter ids may not change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Channel { + /// Tone, applied to all three components through a luminance ratio. + Master, + Red, + Green, + Blue, +} + +/// How many curves the operation carries. +pub const CHANNELS: usize = Channel::ALL.len(); + +impl Channel { + /// Every curve, in the order they are applied and presented. + /// + /// Master first because it runs first — see the module documentation for + /// why that is the composition order — and because a list showing the + /// grade before the tone would be describing a different operation. + pub const ALL: [Channel; 4] = [ + Channel::Master, + Channel::Red, + Channel::Green, + Channel::Blue, + ]; + + /// Position in [`Self::ALL`], and so in every array keyed by channel. + pub const fn index(self) -> usize { + match self { + Channel::Master => 0, + Channel::Red => 1, + Channel::Green => 2, + Channel::Blue => 3, + } + } + + /// TRACES: FR-CAT-8 + /// What this channel's parameter ids are prefixed with. + /// + /// **The master's prefix is empty, and that is a compatibility guarantee + /// rather than a saving of two characters.** A sidecar is the + /// authoritative store of an edit (ARCH §6.12) and it keys parameters by + /// `op.param` text, so `tone_curve.p2_y`, written by a build that had only + /// one curve, has to keep meaning the master's third point for as long as + /// those files exist. Every id that existed before the channels did is + /// therefore still spelled exactly as it was, and the new ones are spelled + /// differently — rather than the old ones being renamed into a scheme that + /// reads more evenly and loses every edit in the field. + /// + /// It is also why the channel is a *prefix*. A suffix would collide with + /// the axis — `p2_y_r` is one underscore from a point called `y_r` — and + /// the parse in [`ToneCurve::index_of`] would have to read the end of the + /// string to know how to read the beginning of it. + pub const fn prefix(self) -> &'static str { + match self { + Channel::Master => "", + Channel::Red => "r_", + Channel::Green => "g_", + Channel::Blue => "b_", + } + } + + /// The WGSL vector component this curve is applied to, or `None` for the + /// master, which acts on all three through luminance. + const fn component(self) -> Option<&'static str> { + match self { + Channel::Master => None, + Channel::Red => Some("r"), + Channel::Green => Some("g"), + Channel::Blue => Some("b"), + } + } + + /// The localisation key naming this curve. + /// + /// A key, not a word: resolving one needs a localiser and `core/` must not + /// depend on one (NFR-A11Y-1). It reaches the interface as a + /// [`Facet::subject`] — see [`facet_of`] — which is how a panel comes to + /// draw a channel selector without this file knowing that selectors exist. + pub const fn subject(self) -> LocalizedKey { + LocalizedKey(match self { + Channel::Master => "channel.rgb", + Channel::Red => "channel.red", + Channel::Green => "channel.green", + Channel::Blue => "channel.blue", + }) + } + + /// Where this channel sits on the hue wheel, in degrees. + /// + /// Data about the operation, not a decision about appearance: the red + /// curve genuinely acts on the primary at 0°. Whether a frontend draws a + /// swatch from it, and in what shade, is the frontend's to decide + /// (ARCH §4.3a) — which is why this is a number and not a colour. `None` + /// for the master, whose subject is tone rather than a colour. + pub const fn hue(self) -> Option { + match self { + Channel::Master => None, + Channel::Red => Some(0.0), + Channel::Green => Some(120.0), + Channel::Blue => Some(240.0), + } + } + + /// This channel's ten point parameters, x and y interleaved. + pub fn params(self) -> &'static [ParamId] { + let base = self.index() * POINTS * 2; + &CURVE_PARAMS[base..base + POINTS * 2] + } +} + +/// Which coordinate of a point, for [`coordinate`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Axis { + X, + Y, +} + +/// TRACES: FR-DEV-3 +/// The parameter id for one coordinate of one channel's curve. +/// +/// How a caller outside this module addresses a point. The alternative — forty +/// public constants — would write the layout of the parameter list into every +/// call site, where it would then have to agree forever; a caller that wants +/// point 2 of the blue curve says so. +/// +/// Panics if `point` is out of range, which is a programming error rather than +/// anything a file or a user can cause: ids arriving from a sidecar go through +/// [`ToneCurve::index_of`], which returns an option. +pub fn coordinate(channel: Channel, point: usize, axis: Axis) -> ParamId { + assert!(point < POINTS, "the curve has {POINTS} points"); + let axis = match axis { + Axis::X => 0, + Axis::Y => 1, + }; + CURVE_PARAMS[channel.index() * POINTS * 2 + point * 2 + axis] +} + +// The master curve's parameter ids, named because they were named before the +// channels existed: the tests, the develop example and the history's +// coalescing test all address points through them, and a sidecar in the field +// spells them exactly like this. pub const P0_X: ParamId = ParamId("p0_x"); pub const P0_Y: ParamId = ParamId("p0_y"); pub const P1_X: ParamId = ParamId("p1_x"); @@ -56,9 +255,53 @@ pub const P3_Y: ParamId = ParamId("p3_y"); pub const P4_X: ParamId = ParamId("p4_x"); pub const P4_Y: ParamId = ParamId("p4_y"); -/// The parameters the curve widget owns, in point order. -static CURVE_PARAMS: [ParamId; POINTS * 2] = - [P0_X, P0_Y, P1_X, P1_Y, P2_X, P2_Y, P3_X, P3_Y, P4_X, P4_Y]; +/// The ten point ids of one channel, in point order, x before y. +/// +/// A macro because `concat!` needs literals — the same reason the colour +/// mixer's band parameters are macro-generated — and because writing forty ids +/// out by hand is forty chances to transpose two characters in a way that +/// compiles and silently drives the wrong point. +macro_rules! channel_ids { + ($($prefix:literal),* $(,)?) => { + [$( + ParamId(concat!($prefix, "p0_x")), ParamId(concat!($prefix, "p0_y")), + ParamId(concat!($prefix, "p1_x")), ParamId(concat!($prefix, "p1_y")), + ParamId(concat!($prefix, "p2_x")), ParamId(concat!($prefix, "p2_y")), + ParamId(concat!($prefix, "p3_x")), ParamId(concat!($prefix, "p3_y")), + ParamId(concat!($prefix, "p4_x")), ParamId(concat!($prefix, "p4_y")), + )*] + }; +} + +/// The parameters the curve widget owns: every channel, in point order. +/// +/// The widget claims all forty, so a frontend that draws the curve draws all +/// four of them and no point appears a second time as a stray slider beneath +/// it. The prefixes are the ones [`Channel::prefix`] declares, spelled out +/// again here because `concat!` cannot call a function; `the_ids_match_the_ +/// channel_prefixes` is what stops the two drifting. +static CURVE_PARAMS: [ParamId; CHANNELS * POINTS * 2] = channel_ids!["", "r_", "g_", "b_"]; + +/// The uniform names each channel's fragment reads, x and y interleaved. +/// +/// Parallel to [`CURVE_PARAMS`] and deliberately its own table: uniform names +/// are the shader's business and parameter ids are the sidecar's, and tying +/// the two together would make a rename in one file change the meaning of the +/// other. The master's are unprefixed for the same reason its parameters are — +/// a master-only edit generates the shader it always generated, so nothing +/// that keyed on that source has to notice the channels arriving. +static UNIFORM_NAMES: [[&str; POINTS * 2]; CHANNELS] = [ + ["x0", "y0", "x1", "y1", "x2", "y2", "x3", "y3", "x4", "y4"], + [ + "r_x0", "r_y0", "r_x1", "r_y1", "r_x2", "r_y2", "r_x3", "r_y3", "r_x4", "r_y4", + ], + [ + "g_x0", "g_y0", "g_x1", "g_y1", "g_x2", "g_y2", "g_x3", "g_y3", "g_x4", "g_y4", + ], + [ + "b_x0", "b_y0", "b_x1", "b_y1", "b_x2", "b_y2", "b_x3", "b_y3", "b_x4", "b_y4", + ], +]; /// A coordinate parameter: 0…1 with enough precision to place a point /// exactly, and a default putting the curve on the identity diagonal. @@ -77,35 +320,86 @@ const fn coord(id: &'static str, label: &'static str, default: f32) -> ParamDesc ) } +/// TRACES: FR-DEV-3a +/// Where a coordinate sits in the operation's grid. +/// +/// **This is how the channel dimension reaches the interface without the +/// interface learning what a channel is.** The forty parameters are one +/// control — a point coordinate — applied to four subjects, which is exactly +/// what a [`Facet`] describes and the same shape the colour mixer uses for its +/// twelve hue bands. A panel that groups a widget's parameters by their +/// subject gets a four-way selector over the curves for free, names each entry +/// from the key the channel published, and never contains the word "red". +/// +/// A frontend is free to ignore all of it and render forty sliders; nothing +/// becomes unreachable, it merely reads as forty anonymous coordinates. +const fn facet_of(aspect: &'static str, channel: Channel) -> Facet { + Facet { + // What this parameter adjusts: one coordinate of one point. Four + // parameters share it — the same point on each of the four curves. + aspect: LocalizedKey(aspect), + // What it adjusts it on. + subject: channel.subject(), + subject_hue: channel.hue(), + } +} + +/// One channel's ten descriptors, defaulted onto the identity diagonal. +/// +/// Written out per point rather than looped because a `ParamDescriptor` has to +/// be `const` to live in a `static`, and a const loop cannot build a slice. +macro_rules! channel_params { + ($(($prefix:literal, $channel:expr)),* $(,)?) => { + &[$( + coord(concat!($prefix, "p0_x"), "param.curve.p0_x", 0.0) + .faceted(facet_of("param.curve.p0_x", $channel)), + coord(concat!($prefix, "p0_y"), "param.curve.p0_y", 0.0) + .faceted(facet_of("param.curve.p0_y", $channel)), + coord(concat!($prefix, "p1_x"), "param.curve.p1_x", 0.25) + .faceted(facet_of("param.curve.p1_x", $channel)), + coord(concat!($prefix, "p1_y"), "param.curve.p1_y", 0.25) + .faceted(facet_of("param.curve.p1_y", $channel)), + coord(concat!($prefix, "p2_x"), "param.curve.p2_x", 0.5) + .faceted(facet_of("param.curve.p2_x", $channel)), + coord(concat!($prefix, "p2_y"), "param.curve.p2_y", 0.5) + .faceted(facet_of("param.curve.p2_y", $channel)), + coord(concat!($prefix, "p3_x"), "param.curve.p3_x", 0.75) + .faceted(facet_of("param.curve.p3_x", $channel)), + coord(concat!($prefix, "p3_y"), "param.curve.p3_y", 0.75) + .faceted(facet_of("param.curve.p3_y", $channel)), + coord(concat!($prefix, "p4_x"), "param.curve.p4_x", 1.0) + .faceted(facet_of("param.curve.p4_x", $channel)), + coord(concat!($prefix, "p4_y"), "param.curve.p4_y", 1.0) + .faceted(facet_of("param.curve.p4_y", $channel)), + )*] + }; +} + static DESCRIPTOR: OpDescriptor = OpDescriptor { - // Both, and this is the case the plural exists for: an RGB curve is + // Both, and this is the case the plural exists for: the master curve is // tonal and the per-channel curves are chromatic. Filing it under one // would hide it from half the people looking for it. attributes: &[Attribute::Tone, Attribute::Colour], id: ID, label: LocalizedKey("op.tone_curve"), // Defaults lie on y = x, so a fresh curve is the identity and the - // operation reports itself inactive. - params: &[ - coord("p0_x", "param.curve.p0_x", 0.0), - coord("p0_y", "param.curve.p0_y", 0.0), - coord("p1_x", "param.curve.p1_x", 0.25), - coord("p1_y", "param.curve.p1_y", 0.25), - coord("p2_x", "param.curve.p2_x", 0.5), - coord("p2_y", "param.curve.p2_y", 0.5), - coord("p3_x", "param.curve.p3_x", 0.75), - coord("p3_y", "param.curve.p3_y", 0.75), - coord("p4_x", "param.curve.p4_x", 1.0), - coord("p4_y", "param.curve.p4_y", 1.0), + // operation reports itself inactive — on every channel. + // + // The master's ten come first, and stay first: a frontend addresses a + // point by its offset from the first parameter of the run it is drawing, + // and this is also the order one falling back to sliders reads them in. + params: channel_params![ + ("", Channel::Master), + ("r_", Channel::Red), + ("g_", Channel::Green), + ("b_", Channel::Blue), ], }; -static CURVE_HELPERS: &[Helper] = &[ - helpers::LUMINANCE, - helpers::APPLY_TONE_GAIN, - Helper { - name: "curve_span", - source: "\ +/// One span of a monotone cubic Hermite spline. Shared by all four curves. +const CURVE_SPAN: Helper = Helper { + name: "curve_span", + source: "\ // One span of a monotone cubic Hermite spline. // // Takes the span's endpoints and the secants either side of it, rather than @@ -158,15 +452,20 @@ fn curve_span( return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1; }", - }, - Helper { - name: "curve_eval", - source: "\ -// Evaluate the five-point tone curve at `x`. +}; + +/// The five-point evaluation. Shared by all four curves — one function, called +/// with whichever curve's points the caller holds, rather than four copies +/// that could be improved one at a time. +const CURVE_EVAL: Helper = Helper { + name: "curve_eval", + source: "\ +// Evaluate a five-point curve at `x`. // // Spans are unrolled and secants passed explicitly; see `curve_span` for why // there is no array indexing here. Points arrive pre-sorted with a minimum -// separation enforced on the CPU, so no division can be by zero. +// separation enforced on the CPU — per curve, so one channel's points cannot +// be rescued by another's — so no division can be by zero. fn curve_eval( x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32, x4: f32, y4: f32, x: f32, @@ -188,19 +487,102 @@ fn curve_eval( if (x < x3) { return curve_span(x2, y2, x3, y3, s1, s3, x); } return curve_span(x3, y3, x4, y4, s2, s3, x); }", - }, +}; + +/// One colour component through its own curve. +const CHANNEL_CURVE: Helper = Helper { + name: "channel_curve", + source: "\ +// One colour component through its own curve, on the display-referred axis. +// +// The same encode-curve-decode as the master's, and for the same reason: the +// widget draws a 0..1 grid, so a point placed at the middle of it has to mean +// the middle of the visible range rather than the middle of an unbounded +// scene-referred one. +// +// What differs is that this is applied to the component *directly* rather than +// as a ratio over luminance. That is the whole point of a per-channel curve — +// it changes the proportions between the components, which is what makes it +// chromatic where the master is tonal. +// +// The clamp is the curve's promise rather than an oversight: its last point +// *is* white, so a component arriving above the axis takes the value the curve +// gives at 1. The master does the same to a luminance above 1, through the +// gain it applies; a channel curve that instead let highlights past unchanged +// would tint them differently from every tone below them, which reads as a +// coloured fringe along a blown edge. +fn channel_curve( + v: f32, + x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, + x3: f32, y3: f32, x4: f32, y4: f32, +) -> f32 { + let encoded = pow(clamp(v, 0.0, 1.0), 1.0 / 2.2); + let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded); + return pow(clamp(curved, 0.0, 1.0), 2.2); +}", +}; + +// The three helper sets, one per shape of edit. +// +// Chosen rather than assembled because [`Operation::helpers`] hands back a +// `&'static [Helper]` and there is nowhere to build a list at call time. Three +// statics rather than one union so a master-only edit — the common case — +// declares no function it does not call, and a grade with no tonal work does +// not drag in the luminance machinery it has no use for. + +/// The master curve alone. +static MASTER_HELPERS: &[Helper] = &[ + helpers::LUMINANCE, + helpers::APPLY_TONE_GAIN, + CURVE_SPAN, + CURVE_EVAL, ]; -/// A tone curve through [`POINTS`] movable points. -#[derive(Debug, Clone)] -pub struct ToneCurve { +/// The per-channel curves alone. +static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CHANNEL_CURVE]; + +/// Both. +static ALL_HELPERS: &[Helper] = &[ + helpers::LUMINANCE, + helpers::APPLY_TONE_GAIN, + CURVE_SPAN, + CURVE_EVAL, + CHANNEL_CURVE, +]; + +/// The master curve's fragment: tone, applied as a ratio so hue survives it. +const MASTER_BODY: &str = "\ +let luma = luminance(c); +if (luma > 0.0001) { + // The curve is authored on a display-referred 0..1 axis, which is where + // the eye reads tone and where the widget's grid lives. Scene-referred + // luminance is unbounded, so it is encoded to that axis, curved, and + // decoded back — otherwise a point placed at the middle of the grid + // would not correspond to the middle of the visible range. + let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2); + + let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded); + + let decoded = pow(clamp(curved, 0.0, 1.0), 2.2); + // Applied as a ratio so hue is preserved, exactly as contrast does. + c = apply_tone_gain(c, decoded / luma); +}"; + +/// A five-point monotone spline. +/// +/// One of these per channel. The point *values* live here and the parameter +/// *names* live in [`CURVE_PARAMS`], which is what lets the master keep the +/// ids it was born with while the code below stops caring which curve it is +/// holding. +#[derive(Debug, Clone, Copy)] +struct Curve { xs: [f32; POINTS], ys: [f32; POINTS], } -impl Default for ToneCurve { - fn default() -> Self { - // The identity diagonal. +impl Curve { + /// The identity diagonal. + const fn identity() -> Self { let mut xs = [0.0f32; POINTS]; let mut ys = [0.0f32; POINTS]; let mut i = 0; @@ -212,32 +594,15 @@ impl Default for ToneCurve { } Self { xs, ys } } -} - -impl ToneCurve { - pub fn new() -> Self { - Self::default() - } - - /// Map a parameter id to `(point index, is_y)`. - fn index_of(id: ParamId) -> Option<(usize, bool)> { - let (point, axis) = id.0.split_once('_')?; - let index: usize = point.strip_prefix('p')?.parse().ok()?; - if index >= POINTS { - return None; - } - match axis { - "x" => Some((index, false)), - "y" => Some((index, true)), - _ => None, - } - } /// The x coordinates, sorted and separated. /// /// The widget cannot reorder points, but a sidecar can carry anything and /// a spline through unordered or coincident x values divides by zero. - /// Enforced here so the shader never has to check. + /// Enforced here so the shader never has to check — and enforced on each + /// curve independently, because a NaN on the blue channel blanks the image + /// exactly as thoroughly as one on the master, and it is the one nobody + /// thinks to try. fn sorted_xs(&self) -> [f32; POINTS] { const MIN_GAP: f32 = 0.001; let mut xs = self.xs; @@ -259,7 +624,7 @@ impl ToneCurve { xs } - /// Whether the curve differs from the identity. + /// Whether this curve differs from the identity. fn differs_from_identity(&self) -> bool { self.xs .iter() @@ -268,6 +633,76 @@ impl ToneCurve { } } +/// TRACES: FR-DEV-3 +/// A master tone curve and one curve per colour channel. +#[derive(Debug, Clone)] +pub struct ToneCurve { + /// Indexed by [`Channel::index`]. + curves: [Curve; CHANNELS], +} + +impl Default for ToneCurve { + fn default() -> Self { + Self { + curves: [Curve::identity(); CHANNELS], + } + } +} + +impl ToneCurve { + pub fn new() -> Self { + Self::default() + } + + /// TRACES: FR-CAT-8 + /// Map a parameter id to `(channel, point index, is_y)`. + /// + /// **An id with no channel prefix is the master curve**, which is what + /// makes a sidecar written before the per-channel curves existed load and + /// mean what it meant: `p2_y` was the master's third point then and parses + /// to the master's third point now. Nothing needs a version check, because + /// nothing was renamed — the new curves took new names instead. + /// + /// Only the three known prefixes are recognised, so an id from a *newer* + /// build naming a curve this one does not have falls out as `None` and is + /// warned about, rather than being read as some other point. The sidecar + /// preserves the line either way (see [`crate::sidecar`]), so the edit + /// survives the round trip through a build that cannot apply it. + fn index_of(id: ParamId) -> Option<(Channel, usize, bool)> { + let (channel, rest) = match id.0.split_once('_') { + Some(("r", rest)) => (Channel::Red, rest), + Some(("g", rest)) => (Channel::Green, rest), + Some(("b", rest)) => (Channel::Blue, rest), + // No recognised prefix: the id names the master's own point, in + // the spelling it has always had. + _ => (Channel::Master, id.0), + }; + + let (point, axis) = rest.split_once('_')?; + let index: usize = point.strip_prefix('p')?.parse().ok()?; + if index >= POINTS { + return None; + } + match axis { + "x" => Some((channel, index, false)), + "y" => Some((channel, index, true)), + _ => None, + } + } + + fn curve(&self, channel: Channel) -> &Curve { + &self.curves[channel.index()] + } + + /// Whether any per-channel curve contributes anything. + fn channels_active(&self) -> bool { + Channel::ALL + .iter() + .filter(|c| c.component().is_some()) + .any(|c| self.curve(*c).differs_from_identity()) + } +} + impl Operation for ToneCurve { fn descriptor(&self) -> &'static OpDescriptor { &DESCRIPTOR @@ -275,22 +710,22 @@ impl Operation for ToneCurve { fn set_param(&mut self, id: ParamId, value: f32) { match Self::index_of(id) { - Some((i, true)) => self.ys[i] = value, - Some((i, false)) => self.xs[i] = value, + Some((c, i, true)) => self.curves[c.index()].ys[i] = value, + Some((c, i, false)) => self.curves[c.index()].xs[i] = value, None => log::warn!("tone_curve: unknown parameter {id}"), } } fn param(&self, id: ParamId) -> f32 { match Self::index_of(id) { - Some((i, true)) => self.ys[i], - Some((i, false)) => self.xs[i], + Some((c, i, true)) => self.curve(c).ys[i], + Some((c, i, false)) => self.curve(c).xs[i], None => 0.0, } } fn is_active(&self) -> bool { - self.differs_from_identity() + self.curves.iter().any(Curve::differs_from_identity) } fn presentation(&self) -> Option { @@ -305,88 +740,96 @@ impl Operation for ToneCurve { two_dimensional: true, precise_pointing: true, }, + // All four curves. A widget claiming only the master's ten would + // leave the other thirty stranded as sliders beneath the plot; + // which of the four it draws at a time is its own affair, and the + // facets are what let it decide without naming a channel. params: &CURVE_PARAMS, }) } fn wgsl_body(&self) -> String { - "\ -let luma = luminance(c); -if (luma > 0.0001) { - // The curve is authored on a display-referred 0..1 axis, which is where - // the eye reads tone and where the widget's grid lives. Scene-referred - // luminance is unbounded, so it is encoded to that axis, curved, and - // decoded back — otherwise a point placed at the middle of the grid - // would not correspond to the middle of the visible range. - let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2); + let mut body = String::new(); - let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded); + // Tone first, then colour on top of it — see the module documentation + // for why this order and not the other one. + if self.curve(Channel::Master).differs_from_identity() { + body.push_str(MASTER_BODY); + body.push('\n'); + } - let decoded = pow(clamp(curved, 0.0, 1.0), 2.2); - // Applied as a ratio so hue is preserved, exactly as contrast does. - c = apply_tone_gain(c, decoded / luma); -} -c = max(c, vec3(0.0));" - .into() + for channel in Channel::ALL { + let Some(component) = channel.component() else { + continue; + }; + // An untouched channel is not a curve evaluated to the identity; + // it is nothing at all in the generated source. + if !self.curve(channel).differs_from_identity() { + continue; + } + // The arguments come out of the same table the uniforms are + // declared from, so a call and its uniform block cannot disagree + // about a name. + let args = UNIFORM_NAMES[channel.index()].join(", "); + let _ = writeln!(body, "c.{component} = channel_curve(c.{component}, {args});"); + } + + // Whichever curves ran, the result has to be a colour: the spline's + // tangents can carry a point at the floor a very small distance below + // zero, and a negative component poisons every operation after this + // one. + body.push_str("c = max(c, vec3(0.0));"); + body } fn uniforms(&self) -> Vec { - let xs = self.sorted_xs(); - vec![ - Uniform { - name: "x0", - value: xs[0], - }, - Uniform { - name: "x1", - value: xs[1], - }, - Uniform { - name: "x2", - value: xs[2], - }, - Uniform { - name: "x3", - value: xs[3], - }, - Uniform { - name: "x4", - value: xs[4], - }, - Uniform { - name: "y0", - value: self.ys[0], - }, - Uniform { - name: "y1", - value: self.ys[1], - }, - Uniform { - name: "y2", - value: self.ys[2], - }, - Uniform { - name: "y3", - value: self.ys[3], - }, - Uniform { - name: "y4", - value: self.ys[4], - }, - ] + let mut out = Vec::new(); + for channel in Channel::ALL { + let curve = self.curve(channel); + // An untouched curve declares nothing, which is what makes three + // unused curves cost nothing rather than thirty uniform slots. + if !curve.differs_from_identity() { + continue; + } + let names = &UNIFORM_NAMES[channel.index()]; + let xs = curve.sorted_xs(); + for i in 0..POINTS { + out.push(Uniform { + name: names[i * 2], + value: xs[i], + }); + out.push(Uniform { + name: names[i * 2 + 1], + value: curve.ys[i], + }); + } + } + out } fn helpers(&self) -> &'static [Helper] { - CURVE_HELPERS + match ( + self.curve(Channel::Master).differs_from_identity(), + self.channels_active(), + ) { + (true, false) => MASTER_HELPERS, + (false, true) => CHANNEL_HELPERS, + // Both — and the fourth case, neither, which the composer never + // asks because an inactive operation is skipped whole. + _ => ALL_HELPERS, + } } } -/// Evaluate the curve on the CPU. +/// Evaluate a curve on the CPU. /// /// The same maths as the shader, used by the widget to draw the line it is /// editing. Duplicating it is deliberate: the alternative is a GPU readback /// per frame to draw a 200-pixel polyline (ARCH §6.1), and the shared tests /// below pin the two implementations to the same values. +/// +/// Takes points rather than a channel because it has no idea which curve it is +/// drawing and does not need one — four curves are four calls. pub fn evaluate(xs: &[f32; POINTS], ys: &[f32; POINTS], x: f32) -> f32 { if x <= xs[0] { return ys[0]; @@ -457,10 +900,13 @@ mod tests { use super::*; fn identity() -> ([f32; POINTS], [f32; POINTS]) { - let c = ToneCurve::new(); + let c = Curve::identity(); (c.xs, c.ys) } + /// The three curves that are not the master. + const COLOURS: [Channel; 3] = [Channel::Red, Channel::Green, Channel::Blue]; + #[test] fn a_fresh_curve_is_the_identity_and_inactive() { // Opening an unedited image must show the image. @@ -490,7 +936,83 @@ mod tests { p.id ); } - assert_eq!(DESCRIPTOR.params.len(), POINTS * 2); + assert_eq!(DESCRIPTOR.params.len(), CHANNELS * POINTS * 2); + } + + #[test] + fn the_ids_match_the_channel_prefixes() { + // `concat!` cannot call `Channel::prefix`, so the prefixes are written + // twice. This is what stops the two spellings drifting apart — which + // would produce a parameter the descriptor declares and `index_of` + // routes somewhere else. + for channel in Channel::ALL { + for (i, id) in channel.params().iter().enumerate() { + assert!( + id.0.starts_with(channel.prefix()), + "{id} is not on {channel:?}" + ); + let point = i / 2; + let is_y = i % 2 == 1; + assert_eq!(ToneCurve::index_of(*id), Some((channel, point, is_y))); + } + } + } + + #[test] + fn the_master_curves_parameters_are_spelled_as_they_always_were() { + // **The sidecar compatibility test.** These ten ids are written into + // every file produced before the per-channel curves existed, and a + // sidecar is the authoritative store of an edit (ARCH §6.12). + // Renaming one — to `m_p2_y`, say, for symmetry with `r_p2_y` — would + // silently drop that point from every edit in the field. + for (i, (x, y)) in [ + (P0_X, P0_Y), + (P1_X, P1_Y), + (P2_X, P2_Y), + (P3_X, P3_Y), + (P4_X, P4_Y), + ] + .into_iter() + .enumerate() + { + assert_eq!(ToneCurve::index_of(x), Some((Channel::Master, i, false))); + assert_eq!(ToneCurve::index_of(y), Some((Channel::Master, i, true))); + assert_eq!(coordinate(Channel::Master, i, Axis::X), x); + assert_eq!(coordinate(Channel::Master, i, Axis::Y), y); + assert!( + DESCRIPTOR.params.iter().any(|p| p.id == x), + "{x} left the descriptor" + ); + } + } + + #[test] + fn a_master_point_from_an_older_sidecar_still_moves_the_master_curve() { + // The same claim from the other end: the *value* arrives where it used + // to, not merely the name. + let mut c = ToneCurve::new(); + c.set_param(ParamId("p2_y"), 0.65); + assert_eq!(c.curve(Channel::Master).ys[2], 0.65); + for channel in COLOURS { + assert!( + !c.curve(channel).differs_from_identity(), + "{channel:?} moved when only the master was set" + ); + } + } + + #[test] + fn each_channel_owns_its_own_points() { + // The failure this guards is one array behind four names: set red, + // read blue, and see red's value. + let mut c = ToneCurve::new(); + for (channel, value) in COLOURS.into_iter().zip([0.6, 0.7, 0.8]) { + c.set_param(coordinate(channel, 2, Axis::Y), value); + } + assert_eq!(c.param(coordinate(Channel::Red, 2, Axis::Y)), 0.6); + assert_eq!(c.param(coordinate(Channel::Green, 2, Axis::Y)), 0.7); + assert_eq!(c.param(coordinate(Channel::Blue, 2, Axis::Y)), 0.8); + assert_eq!(c.param(P2_Y), 0.5, "the master must not have moved"); } #[test] @@ -501,6 +1023,18 @@ mod tests { assert_eq!(c.param(P2_Y), 0.65); } + #[test] + fn moving_a_channel_point_activates_the_operation() { + // An edit that touches only the blue curve is still an edit; an + // `is_active` that looked at the master alone would drop it from the + // shader and show the untouched image. + for channel in COLOURS { + let mut c = ToneCurve::new(); + c.set_param(coordinate(channel, 1, Axis::Y), 0.4); + assert!(c.is_active(), "{channel:?} did not activate the operation"); + } + } + #[test] fn the_curve_passes_through_its_control_points() { // The property that makes the widget honest: the line drawn through @@ -509,14 +1043,15 @@ mod tests { c.set_param(P1_Y, 0.15); c.set_param(P3_Y, 0.85); - let xs = c.sorted_xs(); + let master = c.curve(Channel::Master); + let xs = master.sorted_xs(); for i in 0..POINTS { - let y = evaluate(&xs, &c.ys, xs[i]); + let y = evaluate(&xs, &master.ys, xs[i]); assert!( - (y - c.ys[i]).abs() < 1e-4, + (y - master.ys[i]).abs() < 1e-4, "point {i} at x={} evaluated to {y}, expected {}", xs[i], - c.ys[i] + master.ys[i] ); } } @@ -530,11 +1065,12 @@ mod tests { c.set_param(P1_Y, 0.10); c.set_param(P3_Y, 0.90); - let xs = c.sorted_xs(); + let master = c.curve(Channel::Master); + let xs = master.sorted_xs(); let mut previous = f32::NEG_INFINITY; for i in 0..=200 { let x = i as f32 / 200.0; - let y = evaluate(&xs, &c.ys, x); + let y = evaluate(&xs, &master.ys, x); assert!( y >= previous - 1e-5, "curve decreased at x={x}: {y} after {previous}" @@ -554,16 +1090,37 @@ mod tests { c.set_param(P3_Y, 0.97); c.set_param(P4_Y, 1.0); - let xs = c.sorted_xs(); + let master = c.curve(Channel::Master); + let xs = master.sorted_xs(); let mut previous = f32::NEG_INFINITY; for i in 0..=200 { - let y = evaluate(&xs, &c.ys, i as f32 / 200.0); + let y = evaluate(&xs, &master.ys, i as f32 / 200.0); assert!(y >= previous - 1e-5, "decreased at {i}"); assert!(y.is_finite(), "non-finite at {i}"); previous = y; } } + #[test] + fn a_channel_curve_stays_monotonic() { + // The same guarantee the master carries, and it matters more here: a + // non-monotone blue curve inverts blue locally, which is a hue + // reversal rather than a dark halo — harder to see and much harder to + // attribute to the control that caused it. + let mut c = ToneCurve::new(); + c.set_param(coordinate(Channel::Blue, 1, Axis::Y), 0.05); + c.set_param(coordinate(Channel::Blue, 3, Axis::Y), 0.95); + + let blue = c.curve(Channel::Blue); + let xs = blue.sorted_xs(); + let mut previous = f32::NEG_INFINITY; + for i in 0..=200 { + let y = evaluate(&xs, &blue.ys, i as f32 / 200.0); + assert!(y >= previous - 1e-5, "blue decreased at {i}"); + previous = y; + } + } + #[test] fn a_flat_span_stays_flat() { // Two points at the same height must not bow between them. @@ -572,10 +1129,11 @@ mod tests { c.set_param(P2_Y, 0.5); c.set_param(P3_Y, 0.5); - let xs = c.sorted_xs(); + let master = c.curve(Channel::Master); + let xs = master.sorted_xs(); for i in 0..=20 { let x = 0.25 + (i as f32 / 20.0) * 0.5; - let y = evaluate(&xs, &c.ys, x); + let y = evaluate(&xs, &master.ys, x); assert!((y - 0.5).abs() < 1e-4, "at {x} the flat span gave {y}"); } } @@ -588,35 +1146,41 @@ mod tests { } #[test] - fn coincident_x_values_are_separated() { + fn coincident_x_values_are_separated_on_every_channel() { // A sidecar can carry anything; a spline through two points at the - // same x divides by zero and produces NaN across the image. - let mut c = ToneCurve::new(); - c.set_param(P1_X, 0.5); - c.set_param(P2_X, 0.5); - c.set_param(P3_X, 0.5); + // same x divides by zero and produces NaN across the image. The + // guarantee has to hold per curve, because the sort is per curve. + for channel in Channel::ALL { + let mut c = ToneCurve::new(); + for point in 1..4 { + c.set_param(coordinate(channel, point, Axis::X), 0.5); + } - let xs = c.sorted_xs(); - for i in 1..POINTS { - assert!( - xs[i] > xs[i - 1], - "x values must be strictly increasing, got {xs:?}" - ); - } - // And the result must be usable, not merely non-crashing. - for i in 0..=50 { - assert!(evaluate(&xs, &c.ys, i as f32 / 50.0).is_finite()); + let curve = c.curve(channel); + let xs = curve.sorted_xs(); + for i in 1..POINTS { + assert!( + xs[i] > xs[i - 1], + "{channel:?}: x values must be strictly increasing, got {xs:?}" + ); + } + // And the result must be usable, not merely non-crashing. + for i in 0..=50 { + assert!(evaluate(&xs, &curve.ys, i as f32 / 50.0).is_finite()); + } } } #[test] - fn out_of_order_x_values_are_sorted() { - let mut c = ToneCurve::new(); - c.set_param(P1_X, 0.9); - c.set_param(P3_X, 0.1); - let xs = c.sorted_xs(); - for i in 1..POINTS { - assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}"); + fn out_of_order_x_values_are_sorted_on_every_channel() { + for channel in Channel::ALL { + let mut c = ToneCurve::new(); + c.set_param(coordinate(channel, 1, Axis::X), 0.9); + c.set_param(coordinate(channel, 3, Axis::X), 0.1); + let xs = c.curve(channel).sorted_xs(); + for i in 1..POINTS { + assert!(xs[i] > xs[i - 1], "{channel:?} not sorted: {xs:?}"); + } } } @@ -643,10 +1207,65 @@ mod tests { } } + #[test] + fn the_widgets_parameters_are_grouped_by_the_curve_they_belong_to() { + // What a channel selector is built out of. A panel groups the widget's + // parameters by their facet's subject and gets four curves, in this + // order, without knowing that a colour channel is a thing — so each + // channel's run has to be contiguous, complete, and labelled. + let presentation = ToneCurve::new().presentation().expect("declares a widget"); + for channel in Channel::ALL { + let base = channel.index() * POINTS * 2; + assert_eq!( + &presentation.params[base..base + POINTS * 2], + channel.params(), + "{channel:?}'s points are not contiguous in the widget's list" + ); + for id in channel.params() { + let facet = DESCRIPTOR + .param(*id) + .expect("declared") + .facet + .expect("a curve point says which curve it is on"); + assert_eq!(facet.subject, channel.subject()); + assert_eq!(facet.subject_hue, channel.hue()); + } + } + } + + #[test] + fn the_four_curves_share_one_aspect_per_coordinate() { + // The other half of the grid: the same point on all four curves is one + // control applied to four subjects, which is what makes a panel able to + // draw one plot and change its subject. + for point in 0..POINTS { + for axis in [Axis::X, Axis::Y] { + let aspects: Vec<_> = Channel::ALL + .iter() + .map(|c| { + DESCRIPTOR + .param(coordinate(*c, point, axis)) + .expect("declared") + .facet + .expect("faceted") + .aspect + }) + .collect(); + assert!( + aspects.windows(2).all(|w| w[0] == w[1]), + "point {point} {axis:?} does not share an aspect across the curves" + ); + } + } + } + #[test] fn the_fragment_reads_every_declared_uniform() { let mut c = ToneCurve::new(); c.set_param(P2_Y, 0.7); + for channel in COLOURS { + c.set_param(coordinate(channel, 2, Axis::Y), 0.6); + } let body = c.wgsl_body(); for u in c.uniforms() { assert!( @@ -657,12 +1276,119 @@ mod tests { } } + #[test] + fn an_untouched_channel_costs_nothing() { + // **The property that makes four curves affordable.** A photograph + // edited with the master curve alone must generate what it generated + // when this operation held one curve: the same ten uniforms, the same + // fragment, and not one line about red, green or blue. + let mut c = ToneCurve::new(); + c.set_param(P2_Y, 0.7); + + let body = c.wgsl_body(); + assert!(body.contains("curve_eval("), "the master curve is missing"); + assert!( + !body.contains("channel_curve("), + "an untouched channel reached the shader:\n{body}" + ); + let names: Vec<&str> = c.uniforms().iter().map(|u| u.name).collect(); + assert_eq!(names.len(), POINTS * 2, "only the master declares uniforms"); + assert!( + !names.iter().any(|n| n.contains('_')), + "an untouched channel declared uniforms: {names:?}" + ); + assert_eq!(c.helpers(), MASTER_HELPERS); + } + + #[test] + fn an_untouched_master_costs_nothing() { + // The mirror image, and the case a naive implementation gets wrong: a + // grade with no tonal work should not pay for a luminance evaluation + // that maps every pixel to itself. + let mut c = ToneCurve::new(); + c.set_param(coordinate(Channel::Blue, 0, Axis::Y), 0.08); + + let body = c.wgsl_body(); + assert!( + !body.contains("luminance("), + "the identity master curve reached the shader:\n{body}" + ); + assert!(body.contains("c.b = channel_curve(c.b,")); + assert!(!body.contains("c.r = "), "red was untouched:\n{body}"); + let names: Vec<&str> = c.uniforms().iter().map(|u| u.name).collect(); + assert_eq!(names.len(), POINTS * 2); + assert!(names.iter().all(|n| n.starts_with("b_")), "{names:?}"); + assert_eq!(c.helpers(), CHANNEL_HELPERS); + } + + #[test] + fn a_neutral_curve_contributes_no_uniforms_at_all() { + // Belt and braces around `is_active`: the composer skips an inactive + // operation, but one that declared uniforms while claiming to be + // neutral would push the whole uniform block out of step the day that + // changed. + let c = ToneCurve::new(); + assert!(!c.is_active()); + assert!(c.uniforms().is_empty()); + assert_eq!(c.wgsl_body(), "c = max(c, vec3(0.0));"); + } + + #[test] + fn the_master_curve_runs_before_the_channel_curves() { + // **The composition order, asserted rather than described.** The + // channels grade the tones the master produced; the other order is a + // visibly different image, and it is the kind of change that arrives + // by accident when someone reorders a loop. + let mut c = ToneCurve::new(); + c.set_param(P2_Y, 0.7); + c.set_param(coordinate(Channel::Red, 1, Axis::Y), 0.3); + + let body = c.wgsl_body(); + let master = body.find("apply_tone_gain").expect("the master curve runs"); + let red = body.find("c.r = channel_curve").expect("the red curve runs"); + assert!(master < red, "the master curve must run first:\n{body}"); + } + + #[test] + fn the_channels_run_in_the_order_they_are_listed() { + // Not because the result depends on it — the three act on separate + // components — but because a reader comparing the generated shader + // with this file should not have to wonder whether it does. + let mut c = ToneCurve::new(); + for channel in COLOURS { + c.set_param(coordinate(channel, 2, Axis::Y), 0.6); + } + let body = c.wgsl_body(); + let at = |s: &str| body.find(s).unwrap_or_else(|| panic!("{s} missing")); + assert!(at("c.r = ") < at("c.g = ")); + assert!(at("c.g = ") < at("c.b = ")); + } + #[test] fn unknown_parameters_are_ignored() { let mut c = ToneCurve::new(); c.set_param(ParamId("p9_x"), 0.5); c.set_param(ParamId("nonsense"), 0.5); c.set_param(ParamId("p1_z"), 0.5); + // A curve from a build that has more of them than this one does. + c.set_param(ParamId("k_p1_y"), 0.5); + c.set_param(ParamId("r_p9_y"), 0.5); assert!(!c.is_active()); } + + #[test] + fn every_channel_is_nameable_and_distinct() { + // The selector is built from these, so two channels sharing a key + // would draw two entries with one name and no way to tell which is + // which. + let mut keys: Vec<&str> = Channel::ALL.iter().map(|c| c.subject().0).collect(); + let before = keys.len(); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(before, keys.len(), "two channels share a name"); + assert_eq!(Channel::ALL.len(), CHANNELS); + for c in Channel::ALL { + assert!(!c.subject().0.is_empty()); + } + } } diff --git a/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 2f21750..9dffe6b 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -1004,7 +1004,7 @@ impl std::error::Error for ParseError {} mod tests { use super::*; use crate::framing; - use crate::ops::{exposure, saturation, white_balance}; + use crate::ops::{curve, exposure, saturation, white_balance}; fn edited() -> EditGraph { let mut g = EditGraph::default_chain(); @@ -1189,6 +1189,90 @@ mod tests { assert_eq!(once, twice); } + /// TRACES: FR-DEV-3 | FR-CAT-8 + /// A file written before the tone curve had per-channel curves. + /// + /// Spelled out as literal text rather than produced by `to_text`, because + /// the claim is about *those bytes*: a sidecar generated by this build + /// would agree with this build by construction, and would go on agreeing + /// with it through a rename that broke every file on disk. + #[test] + fn a_sidecar_from_before_the_channel_curves_still_names_the_master() { + let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 4\nmodified = 9\n\ + tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n"; + let parsed = Sidecar::parse(text).expect("valid"); + let mut g = EditGraph::default_chain(); + parsed.default_version().expect("a version").apply(&mut g); + + // The S-curve the file describes, on the master curve and nowhere + // else. + assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15)); + assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85)); + for channel in [curve::Channel::Red, curve::Channel::Green, curve::Channel::Blue] { + for point in 0..curve::POINTS { + for axis in [curve::Axis::X, curve::Axis::Y] { + let id = curve::coordinate(channel, point, axis); + let expected = g + .capabilities() + .iter() + .find(|c| c.id == curve::ID) + .and_then(|c| c.params.iter().find(|p| p.id == id)) + .map(|p| p.default); + assert_eq!( + g.param(curve::ID, id), + expected, + "{id} moved, and no line in the file mentions it" + ); + } + } + } + + // And writing it back produces the same two lines: the curves the file + // never mentioned are still at their defaults, so they are still + // absent (`only_non_default_values_are_written`). + let written = Sidecar::parse(&Sidecar::parse(text).expect("valid").to_text()) + .expect("valid") + .to_text(); + assert!(written.contains("tone_curve.p1_y = 0.15"), "{written}"); + assert!(written.contains("tone_curve.p3_y = 0.85"), "{written}"); + assert!( + !written.contains("tone_curve.r_"), + "an untouched channel curve was written out:\n{written}" + ); + } + + /// TRACES: FR-DEV-3 + /// The other direction: the new curves persist like any other parameter. + #[test] + fn a_per_channel_curve_survives_the_round_trip() { + let mut g = EditGraph::default_chain(); + // A faded shadow: blue lifted at the black point, red pulled down. + let blue = curve::coordinate(curve::Channel::Blue, 0, curve::Axis::Y); + let red = curve::coordinate(curve::Channel::Red, 4, curve::Axis::Y); + g.set_param(curve::ID, blue, 0.08); + g.set_param(curve::ID, red, 0.92); + g.set_param(curve::ID, curve::P2_Y, 0.55); + + let mut sidecar = Sidecar::new(); + sidecar.put(version_of(&g)); + let text = sidecar.to_text(); + // Keyed by the channel-prefixed id, which is what makes the master's + // unprefixed ones safe to leave alone. + assert!(text.contains("tone_curve.b_p0_y = 0.08"), "{text}"); + + let parsed = Sidecar::parse(&text).expect("valid"); + let mut restored = EditGraph::default_chain(); + parsed + .default_version() + .expect("a version") + .apply(&mut restored); + + assert_eq!(restored.param(curve::ID, blue), Some(0.08)); + assert_eq!(restored.param(curve::ID, red), Some(0.92)); + assert_eq!(restored.param(curve::ID, curve::P2_Y), Some(0.55)); + assert!(!restored.is_neutral()); + } + #[test] fn an_unknown_operation_survives_a_round_trip() { // The data-loss case that matters: a device running an older build diff --git a/core/dr-pipeline/tests/tone_curve.rs b/core/dr-pipeline/tests/tone_curve.rs new file mode 100644 index 0000000..d54abb4 --- /dev/null +++ b/core/dr-pipeline/tests/tone_curve.rs @@ -0,0 +1,202 @@ +//! TRACES: FR-DEV-3 +//! The tone curve's four curves, seen from outside the crate. +//! +//! The unit tests beside the operation assert what one `ToneCurve` does. These +//! assert the two properties that only show up once it is a node in a chain +//! with a sidecar under it: that a file written before the per-channel curves +//! existed still describes the edit it described, and that the three new +//! curves cost a photograph that does not use them precisely nothing. + +use dr_pipeline::ops::curve::{self, Axis, Channel}; +use dr_pipeline::{EditGraph, Sidecar}; + +/// A version block carrying `params`, in the on-disk spelling. +fn sidecar_with(params: &[(&str, f32)]) -> String { + let mut text = String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n"); + for (key, value) in params { + text.push_str(&format!("{key} = {value}\n")); + } + text +} + +fn apply(text: &str) -> EditGraph { + let sidecar = Sidecar::parse(text).expect("a valid sidecar"); + let mut graph = EditGraph::default_chain(); + sidecar + .default_version() + .expect("a default version") + .apply(&mut graph); + graph +} + +/// TRACES: FR-CAT-8 +/// **The compatibility guarantee, end to end.** +/// +/// An edit made before this build existed has to produce the same image now. +/// Asserted on the generated shader rather than on the parameter values, +/// because that is what the photograph is actually made of: same source, same +/// uniforms, same picture. +#[test] +fn an_edit_written_before_the_channel_curves_renders_as_it_did() { + let from_file = apply(&sidecar_with(&[ + ("tone_curve.p1_y", 0.15), + ("tone_curve.p3_y", 0.85), + ])); + + let mut by_hand = EditGraph::default_chain(); + by_hand.set_param(curve::ID, curve::P1_Y, 0.15); + by_hand.set_param(curve::ID, curve::P3_Y, 0.85); + + let restored = from_file.compose(); + let expected = by_hand.compose(); + assert_eq!(restored.source, expected.source); + assert_eq!(restored.uniforms, expected.uniforms); + assert_eq!(restored.structure_hash, expected.structure_hash); +} + +/// **Neutral means absent, and stays absent with four times as much to be +/// neutral about.** +/// +/// The curve carries forty parameters now. An image edited with an S-curve and +/// nothing else must generate the shader it generated when it carried ten: +/// three untouched curves are not three identity evaluations, they are nothing +/// at all. +#[test] +fn three_untouched_curves_cost_nothing() { + let mut graph = EditGraph::default_chain(); + graph.set_param(curve::ID, curve::P2_Y, 0.62); + let shader = graph.compose(); + + assert!( + shader.source.contains("---- tone_curve ----"), + "the master curve must reach the shader" + ); + assert!( + !shader.source.contains("channel_curve"), + "an untouched channel curve reached the shader:\n{}", + shader.source + ); + for prefix in ["r_", "g_", "b_"] { + assert!( + !shader.source.contains(&format!("tone_curve_{prefix}")), + "an untouched channel curve declared uniforms:\n{}", + shader.source + ); + } +} + +/// And with none of them touched, the operation is not there at all — the +/// property `a_fresh_graph_is_neutral` asserts for the chain, restated for the +/// node that just quadrupled in size. +#[test] +fn a_curve_at_its_defaults_is_absent_from_the_shader() { + let graph = EditGraph::default_chain(); + assert!(graph.is_neutral()); + assert!(!graph.compose().source.contains("tone_curve")); + + // Including when every one of the forty parameters has been explicitly + // written to its own default, which is what a sidecar round trip through + // a build with a different idea of "default" would produce. + let mut written = EditGraph::default_chain(); + for cap in written.capabilities() { + if cap.id != curve::ID { + continue; + } + for p in &cap.params { + written.set_param(curve::ID, p.id, p.default); + } + } + assert!(written.is_neutral()); +} + +/// A grade with no tonal work is a real edit, and it must not drag the +/// luminance path in behind it. +#[test] +fn a_channel_curve_reaches_the_shader_on_its_own() { + let mut graph = EditGraph::default_chain(); + graph.set_param( + curve::ID, + curve::coordinate(Channel::Blue, 0, Axis::Y), + 0.08, + ); + + let shader = graph.compose(); + assert!(shader.source.contains("c.b = channel_curve(c.b,")); + assert!( + !shader.source.contains("apply_tone_gain"), + "the identity master curve reached the shader:\n{}", + shader.source + ); + // Blue's ten points, and nothing else: the other two curves are at the + // identity and contribute no slot to the uniform block. + for prefix in ["r_", "g_"] { + assert!( + !shader.source.contains(&format!("tone_curve_{prefix}")), + "an untouched channel curve declared uniforms:\n{}", + shader.source + ); + } + for point in 0..curve::POINTS { + assert!( + shader.source.contains(&format!("tone_curve_b_x{point}")), + "blue's point {point} is missing from the uniform block" + ); + } +} + +/// Each curve is its own shader, so the pipeline cache cannot hand the red +/// curve's compiled program to an edit that moved the green one. +#[test] +fn every_curve_generates_a_distinct_shader() { + let mut hashes: Vec = Vec::new(); + for channel in Channel::ALL { + let mut graph = EditGraph::default_chain(); + graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.62); + hashes.push(graph.compose().structure_hash); + } + let before = hashes.len(); + hashes.sort_unstable(); + hashes.dedup(); + assert_eq!(before, hashes.len(), "two curves share a compiled shader"); +} + +/// The forty parameters all persist, and the file names each one once. +#[test] +fn every_point_of_every_curve_round_trips_through_a_sidecar() { + let mut graph = EditGraph::default_chain(); + // A different y per coordinate, so a point wired to the wrong channel + // cannot pass by holding the value it was supposed to hold anyway. + // Thirty-secondths because they survive both the decimal the file is + // written in and the binary32 it is read back into exactly — this test is + // about which parameter a value lands in, and a rounding difference here + // would fail it for an unrelated reason. + let mut step = 1; + for channel in Channel::ALL { + for point in 0..curve::POINTS { + graph.set_param( + curve::ID, + curve::coordinate(channel, point, Axis::Y), + step as f32 / 32.0, + ); + step += 1; + } + } + + let mut sidecar = Sidecar::new(); + sidecar.put(dr_pipeline::sidecar::Version::from_graph( + "u1", "Default", &graph, + )); + let restored = apply(&sidecar.to_text()); + + for channel in Channel::ALL { + for point in 0..curve::POINTS { + let id = curve::coordinate(channel, point, Axis::Y); + assert_eq!( + restored.param(curve::ID, id), + graph.param(curve::ID, id), + "{id} did not survive the sidecar" + ); + } + } +} + From 13d003b89db4eb8a8bad652023f825979495954e Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 16:06:21 +0200 Subject: [PATCH 05/27] Let the curve widget plot whichever curve is asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An operation with four curves and a panel that draws one plot needs a way to say which. The panel finds out the way it finds out everything else: the points are faceted with the subject they act on, consecutive parameters sharing a subject are one curve, and a widget spanning several of them gets a selector over their names. Nothing in ui/ contains the word "red", and an operation that grows a fifth curve arrives with a fifth chip. The names ride on the panel rather than on the curve's row, because a Slint model is compared by identity: a fresh list built on every parameter event would make the row look changed every time, and rewriting a row rebuilds the element holding the drag in progress. That is the hazard the in-place point update already exists to avoid. Which curve is on show is interface state, not an edit. It changes no pixel, so it takes no history step, reaches no sidecar, and redraws nothing — the photograph on screen is already right. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/src/develop.rs | 398 ++++++++++++++++++++++++++++++++++----- ui/dr-ui/src/labels.rs | 13 ++ ui/dr-ui/src/lib.rs | 34 ++++ ui/dr-ui/ui/adjust.slint | 72 +++++-- ui/dr-ui/ui/app.slint | 10 + 5 files changed, 468 insertions(+), 59 deletions(-) diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 7367f3c..f90d10e 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -92,6 +92,20 @@ pub struct DevelopSession { /// attributes leaves it at — the tabs are the interface's idea, not the /// core's, and nothing breaks without them (ARCH §4.3a). active_tab: Option, + /// TRACES: FR-DEV-3 + /// Which of the curve widget's subjects the panel is plotting. + /// + /// The tone curve is four curves — one over tone and one per colour + /// channel — and one square plot draws one of them at a time. The index + /// is into the subjects the operation's parameters are faceted on, in the + /// order it declares them, so nothing here knows that "red" exists. + /// + /// **Interface state, not part of the edit.** It changes no pixel, so it + /// is not a parameter, it is not in the graph, it is not in the sidecar + /// and it is not on the undo stack — the same standing as which tab is + /// open. One value rather than one per operation, for the same reason + /// `curve_samples` is one polyline: the panel draws one curve. + curve_channel: usize, } impl DevelopSession { @@ -157,6 +171,7 @@ impl DevelopSession { active_mask: None, show_overlay: false, active_tab: None, + curve_channel: 0, } } @@ -167,8 +182,12 @@ impl DevelopSession { pub fn rows(&self) -> Vec { let caps = self.scoped_capabilities(); match self.active_tab { - Some(attribute) => rows_filtered(&caps, |op| op.attributes.contains(&attribute)), - None => rows_from(&caps), + Some(attribute) => rows_filtered( + &caps, + |op| op.attributes.contains(&attribute), + self.curve_channel, + ), + None => rows_filtered(&caps, |_| true, self.curve_channel), } } @@ -202,8 +221,10 @@ impl DevelopSession { .into_iter() .filter(|a| *a != Attribute::Geometry) .filter(|a| { - caps.iter() - .any(|c| c.attributes.contains(a) && !rows_filtered(&caps, |o| o.attributes.contains(a)).is_empty()) + caps.iter().any(|c| { + c.attributes.contains(a) + && !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty() + }) }) .map(|a| (a, crate::labels::resolve(a.label().0))) .collect() @@ -312,8 +333,13 @@ pub(crate) fn supported(widget: WidgetKind) -> bool { /// FR-DEV-3c acceptance test asks for — an operation the frontend has never /// heard of appearing in a generated panel — and it cannot be asserted at all /// if generating a row requires a device. +/// +/// `#[cfg(test)]` since the panel began passing the selected curve down: the +/// session always has one to pass, and a wrapper that quietly picked the first +/// would be a second answer to a question the session already answers. +#[cfg(test)] pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { - rows_filtered(caps, |_| true) + rows_filtered(caps, |_| true, 0) } /// The panel model for the capabilities `keep` accepts. @@ -326,9 +352,16 @@ pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec { /// /// Getting that backwards is how a slider ends up driving a different /// operation, which is the kind of fault that looks like a rendering bug. +/// +/// `curve_channel` is which subject a multi-subject widget is showing — the +/// tone curve's four curves are one plot with a selector over it. It is passed +/// in rather than read from anywhere because this function is deliberately +/// free-standing: the descriptor-to-panel path has to be exercisable against a +/// hand-built capability list with no session behind it. pub(crate) fn rows_filtered( caps: &[OpCapability], keep: impl Fn(&OpCapability) -> bool, + curve_channel: usize, ) -> Vec { let mut rows = Vec::new(); for (op_index, op) in caps.iter().enumerate() { @@ -376,7 +409,9 @@ pub(crate) fn rows_filtered( // to the core stops this compiling until someone has decided, // here, whether the panel draws it. let row = match widget { - WidgetKind::ToneCurve => curve_row(op_index, group_head, op, presentation), + WidgetKind::ToneCurve => { + curve_row(op_index, group_head, op, presentation, curve_channel) + } // Canvas-hosted kinds returned above; the rest are not // implemented and reached sliders via `choose`. WidgetKind::ColourWheel @@ -489,8 +524,76 @@ pub(crate) fn rows_filtered( rows } +/// One run of a curve widget's parameters: the points of a single curve. +/// +/// A widget may span several curves — the tone curve is one plot over a master +/// curve and three colour channels — and it says so the way the colour mixer +/// says it has twelve bands: by faceting each parameter with the *subject* it +/// acts on. Consecutive parameters sharing a subject are one curve. +struct CurveRun { + /// The subject's localisation key, or `None` where the widget's parameters + /// carry no facet at all and are therefore a single unnamed curve. + subject: Option<&'static str>, + /// Where this run's points begin in the operation's parameter list. What + /// a drag routes back through, so it must be a position in `op.params` + /// and not in the presentation's list. + base: usize, + /// How many coordinates it holds. + len: usize, +} + +/// TRACES: FR-DEV-3a +/// The curves a curve widget spans, in the order the operation declares them. +/// +/// **This is the whole of the panel's knowledge of colour channels: none.** It +/// groups by whatever subject the parameters carry, so an operation offering a +/// master curve and three channels gets a four-way selector, one offering a +/// single unfaceted curve gets no selector at all, and one that grows a fifth +/// curve tomorrow needs no change here. +/// +/// Returns `None` where the parameters do not look like point coordinates — +/// an odd count, a run that is not contiguous in the capability list — in +/// which case the caller falls back to sliders rather than drawing a widget +/// over a layout it has guessed at. +fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option> { + // Points are x/y pairs, so an odd count means the operation and this code + // disagree about the layout. + if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { + log::warn!("{}: curve widget needs an even parameter count", op.id); + return None; + } + + let mut runs: Vec = Vec::new(); + for id in presentation.params { + // The widget addresses points by offset from the first of its run, so + // a run has to be contiguous in the capability list. + let at = op.params.iter().position(|p| p.id == *id)?; + let subject = op.params[at].facet.as_ref().map(|f| f.subject.0); + + match runs.last_mut() { + Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1, + _ => runs.push(CurveRun { + subject, + base: at, + len: 1, + }), + } + } + + if runs.iter().any(|r| !r.len.is_multiple_of(2)) { + log::warn!("{}: a curve's points are not contiguous", op.id); + return None; + } + Some(runs) +} + /// One row standing for a whole curve. /// +/// `channel` picks which of the widget's curves is plotted; it is clamped +/// rather than validated, because the selection is interface state that +/// outlives a change of photograph and the new image's operation may have +/// fewer curves than the old one's. +/// /// Returns `None` if the operation's parameters do not look like point /// coordinates, in which case the caller falls back to sliders rather than /// rendering a broken widget. @@ -499,38 +602,21 @@ fn curve_row( group_head: usize, op: &OpCapability, presentation: &Presentation, + channel: usize, ) -> Option { - // Points are x/y pairs, so an odd count means the operation and this - // code disagree about the layout. - if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) { - log::warn!("{}: curve widget needs an even parameter count", op.id); - return None; - } + let runs = curve_runs(op, presentation)?; + let run = runs.get(channel.min(runs.len().saturating_sub(1)))?; - // The widget addresses points by offset from the first, so they must - // be contiguous in the capability list. - let base = op - .params + let points: Vec = op.params[run.base..run.base + run.len] .iter() - .position(|p| p.id == presentation.params[0])?; - for (i, id) in presentation.params.iter().enumerate() { - if op.params.get(base + i).map(|p| p.id) != Some(*id) { - log::warn!("{}: curve parameters are not contiguous", op.id); - return None; - } - } - - let points: Vec = presentation - .params - .iter() - .filter_map(|id| op.params.iter().find(|p| p.id == *id)) .map(|p| p.value) .collect(); Some(ParamRow { op_index: op_index as i32, - // The first point parameter; the widget offsets from here. - param_index: base as i32, + // The first point parameter *of the curve on show*; the widget offsets + // from here, so switching curve is what re-points the drag. + param_index: run.base as i32, op_label: labels::resolve(op.label.0).into(), param_label: String::new().into(), // A widget spanning a whole operation is not a row in anyone's @@ -551,21 +637,97 @@ fn curve_row( precision: 4, unit: String::new().into(), points: slint::ModelRc::new(slint::VecModel::from(points)), - // A curve is not a choice between named alternatives. + // A curve is not a choice between named alternatives. The curves it + // can switch between are named on the panel rather than on the row — + // see `DevelopSession::curve_channels` for why they cannot ride here. choices: no_choices(), }) } impl DevelopSession { - /// The curve's shape, sampled for drawing. + /// TRACES: FR-DEV-3 + /// The names of the curves the widget can switch between. + /// + /// Empty where there is only one, which is also the answer for a frontend + /// with no curve at all: a selector over a single choice is a row of + /// nothing. + /// + /// **Derived from the facets, so nothing here names a colour channel.** + /// The operation says its forty points are one control applied to four + /// subjects and publishes a localisation key for each; this resolves the + /// keys and hands over four words. An operation that grew a fifth curve + /// would appear here on its own. + /// + /// A panel property rather than a field on the curve's `ParamRow`, and the + /// reason is Slint's: a row's models are compared by identity, so a fresh + /// list of names built on every parameter event would make the row look + /// changed every time, and rewriting a row rebuilds the repeater item + /// underneath it — destroying the `TouchArea` holding the drag in + /// progress. The same hazard `rows`'s in-place point update exists to + /// avoid. Nothing in this list is a drag target, so up here it is safe to + /// replace wholesale, exactly as [`Self::curve_samples`] is. + pub fn curve_channels(&self) -> Vec { + for op in &self.scoped_capabilities() { + let Some(presentation) = &op.presentation else { + continue; + }; + if presentation.choose(supported) != Some(WidgetKind::ToneCurve) { + continue; + } + let Some(runs) = curve_runs(op, presentation) else { + continue; + }; + if runs.len() < 2 { + continue; + } + return runs + .iter() + .map(|r| r.subject.map(labels::resolve).unwrap_or_default()) + .collect(); + } + Vec::new() + } + + /// Which curve the widget is plotting, as an index into + /// [`Self::curve_channels`]. + pub fn curve_channel(&self) -> i32 { + self.curve_channel as i32 + } + + /// Plot a different one of the operation's curves. + /// + /// Out-of-range indices are ignored rather than clamped: the only thing + /// that can send one is a stale interface event, and quietly moving the + /// selection somewhere the user did not point is worse than doing nothing. + pub fn set_curve_channel(&mut self, index: i32) { + let Ok(index) = usize::try_from(index) else { + return; + }; + if index < self.curve_channels().len() { + self.curve_channel = index; + } + } + + /// The plotted curve's shape, sampled for drawing. /// /// Evaluated with `dr_pipeline`'s own spline, so the line the user drags /// is the line the shader applies. The alternative — reading the curve /// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a /// polyline. + /// + /// The line drawn is the *selected* curve's own shape, not the composition + /// of it with the master. Two curves overlaid on one grid is a plot of two + /// things, and the one being dragged has to be the one whose points are + /// under the pointer. pub fn curve_samples(&self) -> Vec { const SAMPLES: usize = 96; + // The selection is an index over the subjects the panel found, which + // for this operation is its channel order. Clamped rather than + // trusted: a selection made on one photograph outlives the change to + // the next. + let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)]; + let mut xs = [0.0f32; curve::POINTS]; let mut ys = [0.0f32; curve::POINTS]; let mut found = false; @@ -575,16 +737,18 @@ impl DevelopSession { continue; } found = true; - for (i, p) in cap.params.iter().enumerate() { - let point = i / 2; - if point >= curve::POINTS { - break; - } - if i % 2 == 0 { - xs[point] = p.value; - } else { - ys[point] = p.value; - } + // By id rather than by position, so which curve is plotted is + // decided by naming it and not by arithmetic over the parameter + // list. + let value = |id| { + cap.params + .iter() + .find(|p| p.id == id) + .map_or(0.0, |p| p.value) + }; + for i in 0..curve::POINTS { + xs[i] = value(curve::coordinate(channel, i, curve::Axis::X)); + ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y)); } } if !found { @@ -2977,9 +3141,9 @@ mod tests { #[test] fn the_curve_collapses_to_a_single_row() { - // Ten point parameters must appear as one curve control, not ten - // sliders — otherwise the widget and the sliders both render and the - // panel shows the same values twice. + // Every point parameter — all four curves' worth — must appear as one + // curve control, not as forty sliders. Otherwise the widget and the + // sliders both render and the panel shows the same values twice. let graph = EditGraph::default_chain(); let curve_cap = graph .capabilities() @@ -2987,7 +3151,11 @@ mod tests { .find(|c| c.id == curve::ID) .expect("the chain includes a tone curve"); - assert_eq!(curve_cap.params.len(), curve::POINTS * 2); + assert_eq!( + curve_cap.params.len(), + curve::CHANNELS * curve::POINTS * 2, + "a master curve and one per colour channel" + ); let presentation = curve_cap .presentation .as_ref() @@ -3027,6 +3195,144 @@ mod tests { } } + /// The panel's whole knowledge of colour channels, asserted to be none. + /// + /// It groups the widget's parameters by the subject the *operation* put on + /// them and finds four curves; nothing below says "red", and an operation + /// that grew a fifth curve would arrive here on its own. + #[test] + fn a_curve_widget_offers_one_run_per_subject() { + let graph = EditGraph::default_chain(); + let cap = graph + .capabilities() + .into_iter() + .find(|c| c.id == curve::ID) + .expect("tone curve present"); + let presentation = cap.presentation.as_ref().expect("declares a widget"); + + let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation"); + assert_eq!(runs.len(), curve::CHANNELS); + for (i, run) in runs.iter().enumerate() { + assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points"); + assert_eq!(run.base, i * curve::POINTS * 2); + assert!(run.subject.is_some(), "run {i} is unnamed"); + } + } + + #[test] + fn switching_curve_repoints_the_row() { + use slint::Model as _; + + // What a drag routes through. The row's `param_index` is the base of + // the curve *on show*, so picking a different one must move it — if it + // did not, dragging a point on the red curve would write to the + // master's. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let curve_at = caps + .iter() + .position(|c| c.id == curve::ID) + .expect("tone curve present"); + + let mut bases = Vec::new(); + for channel in 0..curve::CHANNELS { + let rows = rows_filtered(&caps, |_| true, channel); + let row = rows + .iter() + .find(|r| r.op_index as usize == curve_at) + .expect("the curve has a row"); + assert_eq!(row.kind, "curve"); + assert_eq!( + row.points.row_count(), + curve::POINTS * 2, + "one curve's points, not all four curves'" + ); + bases.push(row.param_index); + } + + assert_eq!( + bases, + (0..curve::CHANNELS) + .map(|i| (i * curve::POINTS * 2) as i32) + .collect::>() + ); + } + + #[test] + fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() { + // The selection outlives the photograph it was made on, and the next + // image's operation may offer fewer curves. Clamping keeps a plot on + // the grid; the alternative is a curve row that vanishes, which reads + // as the tone curve having disappeared from the panel. + let graph = EditGraph::default_chain(); + let caps = graph.capabilities(); + let rows = rows_filtered(&caps, |_| true, 99); + let row = rows + .iter() + .find(|r| r.kind == "curve") + .expect("the curve still has a row"); + assert_eq!( + row.param_index, + ((curve::CHANNELS - 1) * curve::POINTS * 2) as i32 + ); + } + + #[test] + fn an_operation_whose_points_are_unfaceted_is_one_curve() { + // A curve widget that spans a single unnamed curve — which is what + // this operation was before the channels arrived, and what any other + // node declaring a `tone_curve` widget over ten scalars would be. + // It must draw, and it must offer no choice. + use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand}; + use slint::Model as _; + + static IDS: [ParamId; 4] = [ + ParamId("p0_x"), + ParamId("p0_y"), + ParamId("p1_x"), + ParamId("p1_y"), + ]; + let param = |id: ParamId| ParamCapability { + id, + label: LocalizedKey("param.point"), + kind: ParamKind::Scalar { + min: 0.0, + max: 1.0, + scale: dr_pipeline::Scale::Linear, + unit: Unit::None, + precision: 4, + }, + default: 0.0, + value: 0.0, + facet: None, + }; + let plain = OpCapability { + id: OpId("invented_curve"), + label: LocalizedKey("op.invented_curve"), + active: false, + presentation: Some(Presentation { + widgets: &[WidgetKind::ToneCurve], + demand: WidgetDemand { + two_dimensional: true, + precise_pointing: true, + }, + params: &IDS, + }), + params: IDS.iter().map(|id| param(*id)).collect(), + attributes: &[dr_pipeline::Attribute::Tone], + }; + + let presentation = plain.presentation.as_ref().expect("declares a widget"); + let runs = curve_runs(&plain, presentation).expect("curve-shaped"); + assert_eq!(runs.len(), 1, "one unnamed curve"); + assert_eq!(runs[0].subject, None); + + let rows = rows_from(&[plain]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "curve"); + assert_eq!(rows[0].points.row_count(), IDS.len()); + } + #[test] fn curve_samples_start_on_the_diagonal() { // A fresh curve is the identity, so the drawn line must be the 45° diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index 4d2f242..f40c076 100644 --- a/ui/dr-ui/src/labels.rs +++ b/ui/dr-ui/src/labels.rs @@ -54,6 +54,19 @@ pub fn resolve(key: &str) -> String { "param.channel.sat" => "Saturation".into(), "param.channel.lum" => "Luminance".into(), + // The tone curve's four curves, which its points are *subject* to. + // + // Catalogued rather than derived because the master curve's key would + // otherwise read "Rgb": these are the terms of a four-way choice, and + // one of them miscapitalised is the one the eye goes to. The three + // colours would derive correctly and are written out beside it anyway, + // since a list where one entry is translated and three are guessed is + // the shape a half-finished translation takes. + "channel.rgb" => "RGB".into(), + "channel.red" => "Red".into(), + "channel.green" => "Green".into(), + "channel.blue" => "Blue".into(), + // The hue bands, which a faceted row is *subject* to. // // Catalogued even where `derive` would produce the same word, because diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index c7d45ae..55b3d8e 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -581,8 +581,24 @@ pub(crate) fn sync_rows( // curve must be drawn whatever shape it is in. rows.set_vec(current); curve_moved = true; + + // The curves the widget can switch between, named. They can only + // change with the operation set, which is what this branch means, so + // the walk that derives them is not on the parameter-event path. + let channels: Vec = match session.borrow().as_ref() { + Some(s) => s.curve_channels().into_iter().map(Into::into).collect(), + None => Vec::new(), + }; + window.set_curve_channels(slint::ModelRc::new(slint::VecModel::from(channels))); } + // Which curve is plotted, on every pass. Picking one that happens to be + // shaped like the last — two untouched curves are both the diagonal — + // moves no point, so this cannot ride on the resample below: the chips + // would go on highlighting the curve the user just navigated away from. + let channel = session.borrow().as_ref().map_or(0, |s| s.curve_channel()); + window.set_curve_channel(channel); + if !curve_moved { return; } @@ -1802,6 +1818,24 @@ pub fn run(paths: Vec) -> Result<()> { redraw(&w); }); } + { + // Which of the curve's curves the plot is showing. **No redraw**, and + // that is the whole character of this control: it changes no + // parameter, so the photograph is already correct on screen and + // recomputing it would be a frame spent to produce the same pixels. + // For the same reason it records no history step — there is nothing + // to undo — and the sidecar never hears about it. + let weak = window.as_weak(); + let session = session.clone(); + let rows = rows.clone(); + window.on_curve_channel_picked(move |index| { + let Some(w) = weak.upgrade() else { return }; + if let Some(s) = session.borrow_mut().as_mut() { + s.set_curve_channel(index); + } + sync_rows(&w, &rows, &session); + }); + } // ---- undo and redo (FR-DEV-5) --------------------------------------- // diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 8d81154..49f8f8a 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -365,10 +365,17 @@ component ParamControl inherits Rectangle { in property data; /// Curve rows only; ignored by every other kind. in property <[float]> curve-samples; + /// The curves this widget can plot, named. Empty, or one entry, where + /// there is nothing to choose between — see `curve-channel-picked`. + in property <[string]> curve-channels; + /// Which of `curve-channels` is on the grid. + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + /// Plot a different one of the operation's curves. + callback curve-channel-picked(int); callback drag-changed(bool); height: layout.preferred-height; @@ -420,20 +427,49 @@ component ParamControl inherits Rectangle { } } - if root.data.kind == "curve": CurveEditor { - points: root.data.points; - samples: root.curve-samples; - drag-changed(on) => { root.drag-changed(on); } - // A point carries two parameters, so the parameter index is the - // row's base plus the point's offset. This component still knows - // nothing about which operation it belongs to. - point-moved(point, x, y) => { - root.param-changed( - root.data.op-index, root.data.param-index + point * 2, x); - root.param-changed( - root.data.op-index, root.data.param-index + point * 2 + 1, y); + // A curve, and — where the operation offers more than one — the choice + // of which curve is on the grid. + // + // One plot rather than four stacked ones: the curves are read against + // the diagonal and against each other, which needs the grid large, and + // four grids at a quarter of the width would each be too small to + // place a point in. So the selector switches the subject of a single + // plot, and the names in it come from the core — this file does not + // know that a colour channel is what is being chosen between, only + // that the widget said it spans several named things. + if root.data.kind == "curve": VerticalLayout { + spacing: Theme.gap-sm; + + if root.curve-channels.length > 1: Segmented { + // The operation's own name, which nothing else in this row + // draws: a curve row heads no group, so without this the plot + // would sit in the panel unlabelled. + label: root.data.op-label; + options: root.curve-channels; + selected: root.curve-channel; + picked(i) => { root.curve-channel-picked(i); } + } + + CurveEditor { + points: root.data.points; + samples: root.curve-samples; + drag-changed(on) => { root.drag-changed(on); } + // A point carries two parameters, so the parameter index is + // the row's base plus the point's offset. The base is the + // first point of the curve *on show*, so switching curve + // re-points the drag and this component still knows nothing + // about which operation — or which curve — it is drawing. + point-moved(point, x, y) => { + root.param-changed( + root.data.op-index, root.data.param-index + point * 2, x); + root.param-changed( + root.data.op-index, root.data.param-index + point * 2 + 1, y); + } + // Resetting a curve resets the operation, which is all four of + // them — a photographer who double-clicks to start again means + // the control, not the curve that happens to be on show. + reset => { root.curve-reset(root.data.op-index); } } - reset => { root.curve-reset(root.data.op-index); } } } } @@ -710,9 +746,16 @@ export component AdjustPanel inherits Rectangle { /// The tone curve's sampled shape, evaluated in Rust by the same spline /// the shader runs so the drawn line cannot disagree with the applied one. in property <[float]> curve-samples; + /// The curves the tone curve widget can plot, named by the core. Fewer + /// than two of them means there is nothing to choose and no selector. + in property <[string]> curve-channels; + /// Which of them `curve-samples` and the row's points describe. + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + /// Plot a different one of the curve's curves. + callback curve-channel-picked(int); /// Return every parameter of one operation to its default — the reset on /// a section's own header, beside the panel-wide one. callback op-reset(int); @@ -861,12 +904,15 @@ export component AdjustPanel inherits Rectangle { ParamControl { data: row; curve-samples: root.curve-samples; + curve-channels: root.curve-channels; + curve-channel: root.curve-channel; drag-changed(on) => { root.slider-dragging = on; } param-changed(op, param, v) => { root.param-changed(op, param, v); } param-reset(op, param) => { root.param-reset(op, param); } curve-reset(op) => { root.curve-reset(op); } + curve-channel-picked(i) => { root.curve-channel-picked(i); } } } } diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 8460f8e..6e1512b 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -680,9 +680,14 @@ export component AppWindow inherits Window { // The tone curve's sampled shape, evaluated by the core so the drawn // line and the applied one cannot disagree. in property <[float]> curve-samples; + // The curves that widget can plot, named by the core, and which of them + // `curve-samples` describes. Fewer than two means nothing to choose. + in property <[string]> curve-channels; + in property curve-channel; callback param-changed(int, int, float); callback param-reset(int, int); callback curve-reset(int); + callback curve-channel-picked(int); callback reset-all(); // --- copying settings between photographs (FR-DEV-6) --- @@ -1978,6 +1983,11 @@ in property panel-visible: true; rows: root.adjust-rows; enabled: root.adjust-enabled; curve-samples: root.curve-samples; + curve-channels: root.curve-channels; + curve-channel: root.curve-channel; + curve-channel-picked(i) => { + root.curve-channel-picked(i); + } param-changed(op, param, value) => { root.param-changed(op, param, value); } From 8ea427de3ceb66d4b402f9d1220976d8ba438352 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:01:18 +0200 Subject: [PATCH 06/27] WIP: capture sharpening Checkpoint committed by the coordinator, not by the authoring agent: the session hit its API limit mid-task and left this work uncommitted. Committed so it survives, NOT because it is finished - expect failing tests and half-applied changes. The agent resumes from here. --- core/dr-gpu/tests/capture_sharpen.rs | 503 +++++++++++++ core/dr-pipeline/ops/README.md | 7 +- core/dr-pipeline/ops/capture_sharpen.yaml | 33 + core/dr-pipeline/src/mask.rs | 44 ++ core/dr-pipeline/src/ops/capture_sharpen.rs | 794 ++++++++++++++++++++ core/dr-pipeline/src/ops/mod.rs | 14 +- ui/dr-ui/src/labels.rs | 17 + 7 files changed, 1410 insertions(+), 2 deletions(-) create mode 100644 core/dr-gpu/tests/capture_sharpen.rs create mode 100644 core/dr-pipeline/ops/capture_sharpen.yaml create mode 100644 core/dr-pipeline/src/ops/capture_sharpen.rs diff --git a/core/dr-gpu/tests/capture_sharpen.rs b/core/dr-gpu/tests/capture_sharpen.rs new file mode 100644 index 0000000..4478e6c --- /dev/null +++ b/core/dr-gpu/tests/capture_sharpen.rs @@ -0,0 +1,503 @@ +//! Capture sharpening, end to end on a real device. +//! +//! `dr-pipeline`'s own tests assert what the composer *generates* — the kernel +//! extent, the uniforms, which pass encodes. None of them can tell whether the +//! generated WGSL compiles, whether the second pass is handed what the first +//! one wrote, or whether the result is sharpening rather than a shader that +//! silently produced the input again. Those are questions only a GPU answers. +//! +//! # Reading the expected values +//! +//! The source is uploaded through `DemosaicedImage::from_rgba8`, which flags it +//! non-linear, so the generated shader decodes sRGB before any operation runs +//! and a black/white step reaches the detail stage as linear 0.0 and 1.0 +//! exactly. The last detail pass re-encodes. So a byte read back here is +//! `srgb_encode(whatever the kernel produced in linear light)`, and an +//! overshoot — the bright fringe an unsharp mask puts on the light side of an +//! edge — cannot show above 255 on the white side of a full-scale step, and the +//! undershoot on the dark side of one clips to black long before the halo has +//! been drawn. The tests therefore use a **grey** step, from byte 90 to byte +//! 150, which at 100% amount leaves the whole halo inside the representable +//! range at both ends. Every expected value below is arithmetic on that step, +//! not a number read off a previous run. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::descriptor::ParamId; +use dr_pipeline::ops::capture_sharpen::{AMOUNT, ID, RADIUS, THRESHOLD}; +use dr_pipeline::{Affects, EditGraph}; +use dr_types::ColourSpace; + +fn ctx() -> Option { + // CI runners and headless machines may have no usable adapter. Skip rather + // than fail, exactly as the rest of this crate's device tests do. + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +/// A vertical step from `low` to `high`, changing at the middle column. +/// +/// The one image whose sharpening is worth checking by hand: an unsharp mask +/// must darken the last few columns before the step and brighten the first few +/// after it, and leave everything further away exactly where it was. A gradient +/// would blur to itself and hide a kernel that does nothing at all. +fn step_edge(ctx: &GpuContext, size: u32, low: u8, high: u8) -> DemosaicedImage { + let data: Vec = (0..size * size) + .flat_map(|i| { + let v = if (i % size) < size / 2 { low } else { high }; + [v, v, v, 255] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") +} + +/// A flat field of one value. +fn flat(ctx: &GpuContext, size: u32, value: u8) -> DemosaicedImage { + let data: Vec = (0..size * size) + .flat_map(|_| [value, value, value, 255]) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") +} + +/// One row of the rendered image, red channel, as bytes. +fn row(pixels: &[u8], width: u32, y: u32) -> Vec { + (0..width) + .map(|x| pixels[((y * width + x) * 4) as usize]) + .collect() +} + +/// The develop chain with capture sharpening set. +fn sharpened(amount: f32, radius: f32, threshold: f32) -> EditGraph { + let mut graph = EditGraph::default_chain(); + graph.set_param(ID, AMOUNT, amount); + graph.set_param(ID, RADIUS, radius); + graph.set_param(ID, THRESHOLD, threshold); + graph +} + +/// Render one graph, with its detail stage, and read the pixels back. +/// +/// The whole calling convention a frontend adopts, in five lines: compose both +/// halves from one graph at one output space, ask the graph for the scale, and +/// pass the invalidation key through. +fn render( + pass: &mut AdjustPass, + graph: &EditGraph, + source: &DemosaicedImage, + out: u32, +) -> Vec { + let shader = graph.compose_for(ColourSpace::Srgb); + let scale = graph.render_scale(source.size(), (out, out)); + let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let key = graph.invalidation().through(Affects::Colour); + pass.render_detailed(source, &shader, out, out, None, &detail, key) + .expect("render"); + pass.export_pixels().expect("readback").0 +} + +#[test] +fn an_unsharp_mask_puts_a_halo_on_the_edge_and_leaves_the_rest_alone() { + // What sharpening *is*, asserted as pixels rather than as "something + // changed": an undershoot immediately before the transition, an overshoot + // immediately after it, the step itself steeper than it was, and the flat + // ground at either end untouched. A shader that ran the blur and forgot to + // add the difference back would pass a "the image changed" test and fail + // every one of these. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 64; + let source = step_edge(&ctx, SIZE, 90, 150); + + let plain = render( + &mut AdjustPass::new(&ctx), + &EditGraph::default_chain(), + &source, + SIZE, + ); + let sharp = render( + &mut AdjustPass::new(&ctx), + &sharpened(100.0, 2.0, 0.0), + &source, + SIZE, + ); + + let before = row(&plain, SIZE, SIZE / 2); + let after = row(&sharp, SIZE, SIZE / 2); + let edge = (SIZE / 2) as usize; + + // The dark side of the transition is driven darker and the light side + // lighter — the halo. Two pixels in, where a two-pixel-sigma kernel has + // most of its response. + assert!( + after[edge - 2] < before[edge - 2], + "the dark side of the edge should be pushed down: {} -> {}", + before[edge - 2], + after[edge - 2] + ); + assert!( + after[edge + 1] > before[edge + 1], + "the light side of the edge should be pushed up: {} -> {}", + before[edge + 1], + after[edge + 1] + ); + + // And the transition really is steeper across the same two columns. + let slope = |r: &[u8]| r[edge] as i32 - r[edge - 1] as i32; + assert!( + slope(&after) > slope(&before), + "sharpening must steepen the edge: {} -> {}", + slope(&before), + slope(&after) + ); + + // Far from the edge there is nothing to sharpen, so nothing may move. This + // is the property a kernel that forgot to normalise its weights breaks, + // and it breaks it as a brightness shift over the whole photograph. + for x in [0usize, 4, 8, SIZE as usize - 1] { + assert!( + after[x].abs_diff(before[x]) <= 1, + "column {x} is flat ground and moved: {} -> {}", + before[x], + after[x] + ); + } +} + +#[test] +fn a_flat_field_survives_any_amount_of_sharpening() { + // The kernel sums to one — `(1 + a)` of the pixel minus `a` of its blur — + // so a sky must come through bit for bit however far the slider is pushed. + // The border is the part that is easy to get wrong: `tap` clamps, and a + // kernel that normalised by an analytic integral instead of by the weights + // it actually summed would draw a band around the whole frame. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 48; + let source = flat(&ctx, SIZE, 128); + + let plain = render( + &mut AdjustPass::new(&ctx), + &EditGraph::default_chain(), + &source, + SIZE, + ); + let sharp = render( + &mut AdjustPass::new(&ctx), + &sharpened(100.0, 3.0, 0.0), + &source, + SIZE, + ); + + for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() { + assert!( + a.abs_diff(*b) <= 1, + "pixel {} of a flat field moved: {b} -> {a}", + i / 4 + ); + } +} + +#[test] +fn a_proxy_and_an_export_sharpen_the_same_photograph() { + // TRACES: FR-DSP-1 — the decision this operation is most likely to get + // wrong, and the one that is invisible until an export comes back wrong. + // + // The same edit, rendered at two resolutions of one source. The radius is + // in source pixels, so the halo must cover the same *proportion of the + // picture* at both: a fringe four source pixels wide is four source pixels + // wide whether it was drawn on a half-size proxy or at full size. + // + // Read the radius as render pixels instead and the proxy's halo would be + // twice as wide relative to the frame and roughly twice as strong, so what + // was tuned on screen would not be what landed in the file. That is the + // failure this catches, and it is a large one: the widths would differ by a + // factor of two, not by a rounding. + let Some(ctx) = ctx() else { return }; + const SOURCE: u32 = 128; + let source = step_edge(&ctx, SOURCE, 90, 150); + // Four source pixels, so that even the half-size proxy has a two-pixel + // sigma and resolves the radius — the honest cut-off is tested in + // `dr-pipeline`, and this test is about the case where both renders draw. + let graph = sharpened(100.0, 4.0, 0.0); + + // The halo, measured against the same edit with no sharpening at the same + // size: how far from the transition the picture is still disturbed, as a + // fraction of the frame, and how much deviation the halo carries in total. + let measure = |out: u32| -> (f32, f32) { + let plain = render( + &mut AdjustPass::new(&ctx), + &EditGraph::default_chain(), + &source, + out, + ); + let sharp = render(&mut AdjustPass::new(&ctx), &graph, &source, out); + let (a, b) = (row(&plain, out, out / 2), row(&sharp, out, out / 2)); + + let disturbed: Vec = (0..out as usize) + .filter(|&x| b[x].abs_diff(a[x]) > 3) + .collect(); + let first = *disturbed.first().expect("a halo"); + let last = *disturbed.last().expect("a halo"); + + // The halo's strength as an *area* — the sum of the deviations, scaled + // by the width of a render pixel — rather than as its peak. A peak is + // one sample of a smooth curve, and the two renders do not sample it at + // the same place: the pixel next to the transition sits half a render + // pixel from it, which is half a source pixel at export and a whole one + // on the proxy, so their peaks would legitimately differ by more than + // the property under test. An integral over the same curve does not + // care where the samples fell. + let area: f32 = (0..out as usize) + .map(|x| b[x].abs_diff(a[x]) as f32) + .sum::() + / out as f32; + ((last - first) as f32 / out as f32, area) + }; + + let (proxy_width, proxy_area) = measure(SOURCE / 2); + let (export_width, export_area) = measure(SOURCE); + + assert!( + (proxy_width - export_width).abs() < 0.06, + "the halo covers {proxy_width:.3} of the proxy and {export_width:.3} \ + of the export; a radius tuned on screen must land in the file" + ); + // The strength has to agree too. A viewport-scaled kernel would not only + // be wider on the proxy, it would push the fringe further, because a wider + // blur takes more away for the high-pass to add back — so the areas would + // differ by considerably more than the sampling slack allowed here. + let ratio = proxy_area / export_area; + assert!( + (0.75..1.35).contains(&ratio), + "the halo carries {proxy_area:.2} on the proxy and {export_area:.2} at \ + export, a ratio of {ratio:.2}" + ); + // And both are a real halo rather than two flat images agreeing. + assert!( + proxy_width > 0.05 && export_width > 0.05, + "{proxy_width:.3} / {export_width:.3}" + ); + assert!(proxy_area > 1.0 && export_area > 1.0, "{proxy_area} / {export_area}"); +} + +#[test] +fn the_threshold_leaves_shallow_modulation_where_it_found_it() { + // What the threshold is for: sensor noise is shallow, and sharpening it is + // the fastest way to make a clean frame look worse. Two images, one with a + // strong edge and one with a shallow ripple, through the same gate — the + // edge must still sharpen and the ripple must not. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 64; + + // A four-code ripple: about 4% local contrast at this level, which is the + // order of magnitude read noise reaches on a well-exposed frame — and well + // under the 12.5% at which the gate below starts letting detail through. + let ripple: Vec = (0..SIZE * SIZE) + .flat_map(|i| { + let v = if (i % SIZE) % 2 == 0 { 128u8 } else { 132 }; + [v, v, v, 255] + }) + .collect(); + let ripple = DemosaicedImage::from_rgba8(&ctx, &ripple, SIZE, SIZE).expect("upload"); + let edge = step_edge(&ctx, SIZE, 90, 150); + + let gated = sharpened(100.0, 1.0, 1.0); + let ungated = sharpened(100.0, 1.0, 0.0); + + let spread = |graph: &EditGraph, source: &DemosaicedImage| -> u8 { + let pixels = render(&mut AdjustPass::new(&ctx), graph, source, SIZE); + let line = row(&pixels, SIZE, SIZE / 2); + // Peak-to-peak over the middle of the row, away from the border. + let window = &line[8..24]; + window.iter().max().unwrap() - window.iter().min().unwrap() + }; + + let ripple_open = spread(&ungated, &ripple); + let ripple_gated = spread(&gated, &ripple); + assert!( + ripple_gated < ripple_open, + "the gate must hold shallow modulation back: {ripple_open} -> \ + {ripple_gated}" + ); + + // The edge is deep modulation and must come through the same gate + // sharpened — a threshold that flattens everything is not a threshold. + let plain_edge = { + let pixels = render( + &mut AdjustPass::new(&ctx), + &EditGraph::default_chain(), + &edge, + SIZE, + ); + row(&pixels, SIZE, SIZE / 2) + }; + let gated_edge = { + let pixels = render(&mut AdjustPass::new(&ctx), &gated, &edge, SIZE); + row(&pixels, SIZE, SIZE / 2) + }; + let mid = (SIZE / 2) as usize; + assert!( + gated_edge[mid - 1] < plain_edge[mid - 1], + "a real edge must still sharpen through the gate: {} -> {}", + plain_edge[mid - 1], + gated_edge[mid - 1] + ); +} + +#[test] +fn sharpening_an_edge_does_not_change_its_colour() { + // The reason the high-pass is applied as a gain on the three channels + // rather than as an offset. An offset moves a saturated colour towards + // grey as it brightens it, so a sharpened red roof gets a pink fringe — + // which reads as chromatic aberration and gets blamed on the lens. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 64; + + // A step between two saturated reds of different brightness: the ratios + // between the channels are the colour, and they must survive the halo. + let data: Vec = (0..SIZE * SIZE) + .flat_map(|i| { + if (i % SIZE) < SIZE / 2 { + [80u8, 30, 30, 255] + } else { + [200, 75, 75, 255] + } + }) + .collect(); + let source = DemosaicedImage::from_rgba8(&ctx, &data, SIZE, SIZE).expect("upload"); + + let pixels = render( + &mut AdjustPass::new(&ctx), + &sharpened(60.0, 2.0, 0.0), + &source, + SIZE, + ); + + // Sampled inside the halo, where an additive sharpener would have washed + // the colour out most. + let y = SIZE / 2; + for x in [SIZE / 2 - 2, SIZE / 2 + 1] { + let i = ((y * SIZE + x) * 4) as usize; + let (r, g, b) = (pixels[i] as f32, pixels[i + 1] as f32, pixels[i + 2] as f32); + assert!(r > g && r > b, "the fringe lost its hue at column {x}"); + // Green and blue started equal and must stay equal: an offset would + // keep them equal too, but the *ratio* to red is what moves, and this + // is the assertion that it did not. + let saturation = (r - g) / r; + assert!( + saturation > 0.55, + "column {x} washed out: rgb {r} {g} {b}, saturation {saturation:.3}" + ); + } +} + +#[test] +fn dragging_the_amount_recompiles_nothing_and_reallocates_nothing() { + // The two costs that are ruinous per frame and invisible in the output. + // A sharpening slider is dragged continuously, so this is the difference + // between a control that tracks the mouse and one that stutters. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 48; + let source = step_edge(&ctx, SIZE, 90, 150); + let mut pass = AdjustPass::new(&ctx); + let mut graph = sharpened(40.0, 1.0, 0.0); + + render(&mut pass, &graph, &source, SIZE); + let pipelines = pass.cached_detail_pipelines(); + let allocations = pass.detail_allocations(); + assert_eq!(pipelines, 2, "one per axis of the separable mask"); + assert_eq!(allocations, 2, "the colour result, and one hand-off"); + assert_eq!(pass.detail_dispatches(), 2); + assert_eq!(pass.colour_dispatches(), 1); + + for amount in [50.0, 60.0, 70.0, 80.0] { + graph.set_param(ID, AMOUNT, amount); + render(&mut pass, &graph, &source, SIZE); + } + assert_eq!( + pass.cached_detail_pipelines(), + pipelines, + "an amount is a uniform, not a shader" + ); + assert_eq!( + pass.detail_allocations(), + allocations, + "a steady viewport must allocate nothing" + ); + // TRACES: FR-DEV-3d — and the operational point of `Affects::Detail`: + // sharpening is downstream of every fused operation, so dragging it must + // not re-run them. + assert_eq!( + pass.colour_dispatches(), + 1, + "the fused colour pass re-ran for a change it does not depend on" + ); + + // The radius is also only a uniform, even though it changes the kernel + // extent — the loop bound is read from the uniform block rather than + // baked into the source, which is what keeps a drag off the compiler. + graph.set_param(ID, RADIUS, 2.5); + render(&mut pass, &graph, &source, SIZE); + assert_eq!(pass.cached_detail_pipelines(), pipelines); + graph.set_param(ID, THRESHOLD, 0.3); + render(&mut pass, &graph, &source, SIZE); + assert_eq!(pass.cached_detail_pipelines(), pipelines); +} + +#[test] +fn a_render_too_coarse_for_the_radius_still_reaches_the_screen() { + // The failure mode that the pass-through exists to prevent, proved on a + // device rather than argued about. With the radius finer than a render + // pixel the operation declines to sharpen — but it is still active, so the + // fused pass has already been composed to hand on unclipped linear values, + // and something must still perform the output transform. An empty chain + // here would not be a soft preview: it would be a hard error out of + // `render_detailed`, on the most ordinary develop view there is. + let Some(ctx) = ctx() else { return }; + const SOURCE: u32 = 128; + const RENDER: u32 = 32; // a quarter scale, as a fit view of a large frame + let source = step_edge(&ctx, SOURCE, 90, 150); + + let graph = sharpened(100.0, 1.0, 0.0); + let scale = graph.render_scale((SOURCE, SOURCE), (RENDER, RENDER)); + assert!(!scale.resolves(1.0), "the premise of this test"); + + let mut pass = AdjustPass::new(&ctx); + let sharp = render(&mut pass, &graph, &source, RENDER); + assert_eq!(pass.detail_dispatches(), 1, "one pass, and it only encodes"); + + // And what reaches the screen is the unsharpened picture, not a black + // frame, a linear one, or a guess. + let plain = render( + &mut AdjustPass::new(&ctx), + &EditGraph::default_chain(), + &source, + RENDER, + ); + for (i, (a, b)) in sharp.iter().zip(&plain).enumerate() { + assert!( + a.abs_diff(*b) <= 1, + "pixel {} differs from the unsharpened render: {b} -> {a}", + i / 4 + ); + } +} + +#[test] +fn the_operation_is_reachable_by_the_ids_a_frontend_will_use() { + // FR-DEV-3c: adding an operation needs no UI change, which is only true if + // the panel can find it through the capability list. A typo between the + // declaration's `id:` and the descriptor's would place it in the chain + // under one name and address it under another. + let graph = EditGraph::default_chain(); + let cap = graph + .capabilities() + .into_iter() + .find(|c| c.id == ID) + .expect("capture sharpening is in the default chain"); + let names: Vec = cap.params.iter().map(|p| p.id).collect(); + assert_eq!(names, vec![AMOUNT, RADIUS, THRESHOLD]); + assert!(!cap.active, "a fresh chain is not sharpening anything"); +} diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index dfb8ec8..459d2b2 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -211,7 +211,9 @@ half in Rust would be worse than either alone. Currently hand-written: `tone_curve` (a curve widget over five interpolated points), `colour_mixer` (thirty-six faceted parameters from twelve computed hue -bands). `vignetting` is hand-written too but is not in the develop chain — it +bands), `capture_sharpen` (a separable convolution, which is the other reason a +node is Rust — see the next section). `vignetting` is hand-written too but is +not in the develop chain — it carries lens-profile coefficients that are not parameters. `distortion` and `aberration` are `Warp`s rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it. @@ -239,6 +241,9 @@ rather than a convenience. A node of this kind: each with a WGSL body, its uniforms, and **its kernel radius in render pixels**, which the tile scheduler needs and nothing can infer. +`capture_sharpen` is the worked example: two passes, one per axis, and a radius +converted from source pixels once per render. + The `order:` still belongs here, and still orders the node — among the other detail nodes. Detail runs as a group after every point operation, so an `order:` that interleaves one with exposure would be a lie the chain cannot tell. diff --git a/core/dr-pipeline/ops/capture_sharpen.yaml b/core/dr-pipeline/ops/capture_sharpen.yaml new file mode 100644 index 0000000..f135e7c --- /dev/null +++ b/core/dr-pipeline/ops/capture_sharpen.yaml @@ -0,0 +1,33 @@ +# A hand-written node, and a neighbourhood one: it reads the pixels around the +# one it is writing, so it runs in the detail stage rather than as a fragment +# in the fused pass. See `../src/detail.rs` for why that stage exists and +# `README.md`'s "Nodes that read their neighbours" for the contract. +# +# As with every `rust:` node, its descriptor, parameters and behaviour come +# from the type; this file exists so that `ops/` remains the one place the +# pipeline's order is written down. +id: capture_sharpen +order: 110 + +attributes: [detail] +rust: CaptureSharpen + +why_rust: | + A convolution, not a point function. The schema in `README.md` describes an + operation handed a colour with no way back to a coordinate, which is exactly + what a kernel cannot work with — and stretching it to cover taps, kernel + extents and a per-render conversion from source pixels to render pixels + would produce a worse language than Rust aimed at one caller. + +placement: | + First among the detail nodes, because capture sharpening is a correction to + the capture: it recovers the acutance the anti-aliasing filter, the lens's + circle of confusion and the demosaic interpolation each took out, and it is + meaningful before any effect built on top of it. The compositional detail + controls — texture, clarity — reasonably follow it, since they are about the + picture rather than about the sensor. + + Being in the detail group at all is what places it after every tonal and + chromatic operation: an amount tuned before a tone curve is amplified by + whatever slope that curve happens to have, so the amount that looked right + stops looking right the moment the curve moves. diff --git a/core/dr-pipeline/src/mask.rs b/core/dr-pipeline/src/mask.rs index 76c7d9c..2428cc6 100644 --- a/core/dr-pipeline/src/mask.rs +++ b/core/dr-pipeline/src/mask.rs @@ -934,9 +934,19 @@ impl MaskLayer { /// the output's dimensions, so it is a property of the photograph and not /// of a region within it. There is no such thing as cropping part of an /// image. + /// + /// The neighbourhood operations are absent for the same reason, and this + /// filter is the visible half of the one [`Self::active_ops`] already + /// applies. A layer's chain is fused into the point-operation pass; the + /// detail stage runs once, afterwards, over the whole frame, so there is + /// no seam through which a mask could reach it (see [`crate::detail`]). + /// Offering the controls anyway would put a sharpening slider on a mask + /// that moves and does nothing — which is worse than the control being + /// absent, because absence is legible and a dead slider is not. pub fn capabilities(&self) -> Vec { self.ops .iter() + .filter(|op| op.detail().is_none()) .map(|op| { let desc = op.descriptor(); crate::graph::OpCapability { @@ -1625,4 +1635,38 @@ mod tests { let order: Vec<&str> = stack.layers().iter().map(|l| l.id.as_str()).collect(); assert_eq!(order, ["m3", "m1", "m2"]); } + + #[test] + fn a_layer_offers_no_control_it_cannot_honour() { + // A layer's chain is fused into the point-operation pass, and the + // detail stage runs once afterwards over the whole frame — so a + // neighbourhood operation inside a mask has nowhere to run. + // `active_ops` has always dropped them; this is the other half, which + // stops the panel drawing a sharpening slider on a mask that would + // move and change nothing. + let layer = lit_layer("m1", 1.0); + let global: Vec<&str> = crate::EditGraph::default_chain() + .capabilities() + .iter() + .map(|c| c.id.0) + .collect(); + let scoped: Vec<&str> = layer.capabilities().iter().map(|c| c.id.0).collect(); + + let detail: Vec<&str> = crate::ops::chain() + .iter() + .filter(|o| o.detail().is_some()) + .map(|o| o.descriptor().id.0) + .collect(); + assert!( + !detail.is_empty(), + "the chain has neighbourhood operations, or this proves nothing" + ); + for id in detail { + assert!(global.contains(&id), "{id} is missing from the chain"); + assert!( + !scoped.contains(&id), + "{id} cannot run inside a mask and must not be offered there" + ); + } + } } diff --git a/core/dr-pipeline/src/ops/capture_sharpen.rs b/core/dr-pipeline/src/ops/capture_sharpen.rs new file mode 100644 index 0000000..29a0944 --- /dev/null +++ b/core/dr-pipeline/src/ops/capture_sharpen.rs @@ -0,0 +1,794 @@ +//! TRACES: FR-DEV-3 | FR-DSP-1 +//! Capture sharpening — an unsharp mask against the sensor. +//! +//! Every raw file arrives softer than the scene was. The anti-aliasing filter +//! spreads a point over more than one photosite on purpose, the lens's circle +//! of confusion spreads it further, and the demosaic interpolates two of every +//! three colour samples at each site from its neighbours. None of that is a +//! mistake to be corrected in the developed *picture*; it is a property of how +//! the frame was recorded, and capture sharpening is the step that undoes as +//! much of it as the data supports before anything else is built on top. +//! +//! That distinction is the whole reason this operation exists separately from +//! the output sharpening in `dr-export` (FR-EXP-4). Output sharpening is aimed +//! at a size and a medium — a 900-pixel web image and a matte A2 print want +//! different treatment of the same edit. Capture sharpening is aimed at the +//! sensor, and its answer does not change because the file is going somewhere +//! else. +//! +//! # Unsharp mask, and why the plainest one +//! +//! Blur a copy, subtract it from the original, and add back some multiple of +//! the difference. The difference is everything the blur threw away — the +//! high-frequency content — so adding it back steepens exactly the transitions +//! that the capture chain flattened, and leaves flat areas alone because the +//! blur of a flat area is the area itself. +//! +//! Deconvolution would in principle do better, since the thing being undone +//! really is a convolution with a roughly known kernel. It is also iterative, +//! needs a per-body point-spread estimate this codebase does not have, and +//! amplifies noise in a way that needs its own regularisation. An unsharp mask +//! is the baseline every editor ships and the one a photographer's hands +//! already know; a deconvolution mode can be added later behind the same three +//! parameters without changing what those parameters mean. +//! +//! # Why two passes and not one kernel +//! +//! A Gaussian is separable: blurring along x and then along y gives exactly +//! the same result as a single two-dimensional kernel, at 2(2R+1) taps per +//! pixel instead of (2R+1)². At the radii this operation reaches when zoomed +//! in — a 3-source-pixel radius at 400% is a kernel extent of 36 render pixels +//! — that is 146 taps against 5 329, and it is the difference between a +//! sharpening slider that tracks the mouse and one that does not. +//! +//! [`crate::detail::DetailStage::passes`] returns a *list* precisely so this +//! is expressible: the stage ping-pongs between intermediates, so the second +//! pass is handed what the first one wrote with no plumbing here. +//! +//! ## What the chain can and cannot do, exactly +//! +//! A detail pass reads exactly one texture — whatever ran before it. So the +//! textbook arrangement, "blur in two passes and then subtract the result from +//! the original", is not available: by the time the blur is finished the +//! original is two dispatches behind and nothing is holding it. That is not a +//! gap to be worked around with a third pass either, and it is worth writing +//! down why, because it looks like it should be. +//! +//! Write `Gx`, `Gy` for the two one-dimensional blurs and `Hx = I - Gx`, +//! `Hy = I - Gy` for the high-passes they define. A second pass can form only +//! `α·t + β·Gy(t)` from what the first pass left it in `t`. The result wanted +//! is `(1+a)c - a·GyGx(c)`, whose only occurrence of `c` is under two blurs; +//! matching the `GyGx` term needs `β ≠ 0`, and then the stray `Gy(c)` term +//! that comes with it can only be cancelled by `α·t` if `t` contains `c` +//! unblurred, which the `Gx` in the same expression rules out. No number of +//! extra passes changes this: each one only adds another blur in front. +//! +//! So the two passes each apply a *one-dimensional* unsharp mask, and the +//! composite is the product of the two one-dimensional kernels: +//! +//! ```text +//! (I + a·Hy)(I + a·Hx) = I + a·(Hx + Hy) + a²·HxHy +//! true unsharp = I + a·(Hx + Hy) - a ·HxHy +//! ``` +//! +//! They differ in one term, and that term is worth understanding rather than +//! apologising for. `HxHy` responds only to structure that curves in both +//! directions at once: on any locally one-dimensional feature — which is what +//! an edge is — one of the two factors is zero and **the two agree exactly**. +//! Run this on a vertical edge and it produces the textbook unsharp mask to +//! the last bit. They part company only at corners and at fine two-dimensional +//! texture, where this arrangement sharpens slightly harder, by `a(1+a)` times +//! a quantity that is itself second-order small. +//! +//! Both preserve a flat field exactly: each one-dimensional kernel sums to +//! `(1+a) - a = 1`, so their product does too, and no amount of sharpening +//! shifts the brightness of a sky. +//! +//! # The radius is in source pixels, and that is the decision to check +//! +//! [`crate::detail::RenderScale`] offers two units and the choice between them +//! is the one thing about a neighbourhood operation that is easy to get wrong +//! and invisible when it is. `frame_fraction` is for lengths that are a +//! property of the *composition* — clarity, texture, dehaze, a mask feather — +//! where "one percent of the frame" is what the photographer meant. This +//! radius is not one of those. It stands for the spread of a point across +//! *photosites*, and a body with a stronger anti-aliasing filter needs a +//! larger one at the same framing, so it is stated in source pixels and +//! converted with [`RenderScale::source_pixels`] once per render. +//! +//! Read as render pixels instead, the slider would mean a different photograph +//! at every size: the develop view renders at whatever the viewport needs +//! (FR-DSP-1), so a 60 MP frame in a 2 000 px panel would be sharpened with a +//! kernel nine times too wide relative to the picture, and the export — the +//! only render that is ever kept — would be the one that looked nothing like +//! what was tuned. `the_radius_is_a_sensor_length_not_a_viewport_one` below +//! and `a_proxy_and_an_export_sharpen_the_same_photograph` in `dr-gpu` are the +//! two halves of the proof that it does not. +//! +//! # Where the honest answer is "not at this size" +//! +//! Converting into render pixels does not conjure detail back. On a proxy at +//! one-third scale a one-source-pixel radius is a third of a render pixel, and +//! the frequencies it would act on were destroyed by the downscale before this +//! stage ran. [`RenderScale::resolves`] is the predicate for that condition and +//! this operation obeys it: below one render pixel it stops, rather than +//! drawing a plausible-looking sharpening that the exported file will not +//! contain. That is why every editor tells the photographer to judge +//! sharpening at 1:1 — and zooming to 1:1 is enough, because the framing's +//! view rect shrinks while the render target keeps its size and the ratio +//! climbs back to one. +//! +//! A softer roll-off, fading the amount out as the kernel approaches a pixel +//! rather than stopping at it, would look better while zooming. It is not done +//! because it needs a second threshold that no requirement supplies and that +//! would be a guess dressed as a number; `resolves` is the line the stage +//! already draws, and drawing it in two places differently is worse than a +//! visible step. + +use crate::descriptor::{ + Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit, +}; +use crate::detail::{DetailPass, DetailStage, RenderScale}; +use crate::operation::{Affects, Helper, Operation, Uniform}; +use crate::ops::helpers; + +pub const ID: OpId = OpId("capture_sharpen"); + +pub const AMOUNT: ParamId = ParamId("amount"); +pub const RADIUS: ParamId = ParamId("radius"); +pub const THRESHOLD: ParamId = ParamId("threshold"); + +/// How far out the Gaussian is walked, in standard deviations. +/// +/// Three: beyond that a Gaussian carries under 1.2% of its weight, and the +/// taps cost more than they change. The kernel is normalised by the weights +/// actually summed rather than by an analytic integral, so truncating here +/// costs a slightly narrower effective blur and *not* a brightness shift. +const KERNEL_SIGMAS: f32 = 3.0; + +/// The largest kernel extent, in render pixels, that will be dispatched. +/// +/// Only reachable by zooming past about 16:1, where the ratio climbs above one +/// and a source-pixel radius becomes many render pixels. The cap exists so +/// that a magnification nobody judges sharpening at cannot quietly turn a +/// slider drag into a 200-tap convolution per pass; the price is a Gaussian +/// truncated inside three sigma at those magnifications, which is a slightly +/// tighter blur and nothing else. +const MAX_KERNEL: f32 = 48.0; + +/// The default radius, in source pixels. +/// +/// One photosite. It is what an anti-aliasing filter and a demosaic between +/// them spread a point over on a conventional Bayer sensor, and it is where +/// every editor's capture sharpening starts. +const DEFAULT_RADIUS: f32 = 1.0; + +static DESCRIPTOR: OpDescriptor = OpDescriptor { + id: ID, + label: LocalizedKey("op.capture_sharpen"), + params: &[ + // Amount carries the neutral, which is why it is first: the operation + // is off when this is zero regardless of the other two, so a reset is + // one control and the panel's ordering matches the way it is used. + ParamDescriptor::amount("amount", "param.amount"), + // In **source pixels** — see the module documentation. Half a photosite + // is the smallest radius that means anything on a Bayer sensor, and + // three is already past the point where an unsharp mask is sharpening + // rather than adding local contrast; a photographer wanting the latter + // wants clarity, which is a different operation with a different unit. + ParamDescriptor::scalar( + "radius", + "param.radius", + 0.5, + 3.0, + DEFAULT_RADIUS, + Unit::None, + Scale::Linear, + 2, + ), + // A fraction, but declared as a scalar rather than through + // `ParamDescriptor::fraction` for its precision alone: four decimal + // places on a control whose whole useful travel is a dozen steps + // reads as noise, and invites fiddling with digits that do nothing. + ParamDescriptor::scalar( + "threshold", + "param.threshold", + 0.0, + 1.0, + 0.0, + Unit::None, + Scale::Linear, + 2, + ), + ], + attributes: &[Attribute::Detail], +}; + +/// TRACES: FR-DEV-3 +/// Capture sharpening: a separable unsharp mask with a contrast threshold. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CaptureSharpen { + /// −100…100. Negative softens, which is a real request: a lens that + /// out-resolves the sensor, or a frame with moiré, is better served by + /// backing off the capture chain's acutance than by sharpening it. + amount: f32, + /// The Gaussian's standard deviation, **in source pixels**. + radius: f32, + /// Local contrast below which detail is left alone, 0…1. + threshold: f32, +} + +impl Default for CaptureSharpen { + /// Neutral, and a radius already set to something usable. + /// + /// The radius does not start at its minimum, and this is not the usual + /// "neutral means every parameter at zero" rule being broken: neutrality + /// here is `amount == 0`, and a radius has no neutral value at all — a + /// blur of zero width is not the identity, it is a kernel that does not + /// exist. Starting it at one photosite means dragging the amount up gives + /// a sensible result immediately rather than whatever the low end of the + /// slider happens to be. + fn default() -> Self { + Self { + amount: 0.0, + radius: DEFAULT_RADIUS, + threshold: 0.0, + } + } +} + +impl CaptureSharpen { + pub fn new() -> Self { + Self::default() + } + + /// The Gaussian's standard deviation at `scale`, in **render** pixels. + /// + /// The single place the unit conversion happens, and the reason it is a + /// method rather than a line inside [`Self::passes`]: the tests assert + /// against it, and a test that recomputed the conversion would agree with + /// a bug in it. + pub fn sigma(&self, scale: RenderScale) -> f32 { + scale.source_pixels(self.radius) + } + + /// The kernel extent at `scale`, in render pixels — the halo each pass + /// reads, and what [`DetailPass::radius`] has to state. + /// + /// At least one whenever the pass runs at all: a kernel of extent zero + /// reads one tap, its "blur" is the pixel itself, and the high-pass it + /// produces is identically zero. That would be a dispatch that copies the + /// image, which is not what "sharpen a little" should mean. + pub fn kernel(&self, scale: RenderScale) -> u32 { + let extent = (self.sigma(scale) * KERNEL_SIGMAS).ceil(); + extent.clamp(1.0, MAX_KERNEL) as u32 + } + + /// Whether this render is fine enough to show the radius that was chosen. + /// + /// Delegates to [`RenderScale::resolves`] rather than restating the + /// comparison, so that the line between "sharpened" and "not at this size" + /// is drawn in exactly one place in the codebase. + pub fn resolves(&self, scale: RenderScale) -> bool { + scale.resolves(self.radius) + } +} + +impl Operation for CaptureSharpen { + fn descriptor(&self) -> &'static OpDescriptor { + &DESCRIPTOR + } + + fn set_param(&mut self, id: ParamId, value: f32) { + if id == AMOUNT { + self.amount = value; + } else if id == RADIUS { + self.radius = value; + } else if id == THRESHOLD { + self.threshold = value; + } else { + log::warn!("capture_sharpen: unknown parameter {id}"); + } + } + + fn param(&self, id: ParamId) -> f32 { + if id == AMOUNT { + self.amount + } else if id == RADIUS { + self.radius + } else if id == THRESHOLD { + self.threshold + } else { + 0.0 + } + } + + /// Neutral is `amount == 0`, not "every parameter at its default". + /// + /// A radius and a threshold describe *how* to sharpen and say nothing + /// about whether to; moving either one with the amount at zero must leave + /// the photograph untouched and must not make the edit non-neutral, or an + /// unedited file would open reporting itself modified as soon as anyone + /// brushed the radius slider. + fn is_active(&self) -> bool { + self.amount != 0.0 + } + + /// Never called: a detail operation contributes no fused fragment, and + /// [`crate::operation::compose_full`] filters it out before asking. + fn wgsl_body(&self) -> String { + String::new() + } + + fn uniforms(&self) -> Vec { + Vec::new() + } + + fn affects(&self) -> Affects { + Affects::Detail + } + + fn detail(&self) -> Option<&dyn DetailStage> { + Some(self) + } + + /// The shared Rec. 709 luminance, which in this stage is not the + /// approximation its own documentation warns about: `_helpers.yaml` notes + /// that the weights are only approximate on camera-space values, and the + /// detail stage runs *after* the camera matrix, in linear sRGB, where they + /// are the definition. + fn helpers(&self) -> &'static [Helper] { + &[helpers::LUMINANCE] + } +} + +impl DetailStage for CaptureSharpen { + fn passes(&self, scale: RenderScale) -> Vec { + if !self.resolves(scale) { + return vec![nothing_to_sharpen()]; + } + + let extent = self.kernel(scale); + // Both halves are the same body and the same uniforms, differing only + // in the axis they walk — and the axis is read from the pass index the + // composer already writes into the base uniform block, so there is one + // kernel here rather than two that can drift apart. + ["horizontal", "vertical"] + .into_iter() + .map(|label| DetailPass { + label, + radius: extent, + uniforms: vec![ + Uniform { + // −100…100 as a gain around zero. A hundred percent is + // a strong capture sharpen and not the ceiling of what + // is useful, which is why the control is the familiar + // photographic amount rather than a 0…1 fraction. + name: "amount", + value: self.amount / 100.0, + }, + Uniform { + name: "sigma", + value: self.sigma(scale), + }, + Uniform { + name: "taps", + value: extent as f32, + }, + Uniform { + // The threshold as a local-contrast fraction. A quarter + // at the top of the slider: past about 25% modulation + // the gate has stopped rejecting noise and started + // rejecting the edges the operation exists to sharpen. + name: "gate", + value: self.threshold * 0.25, + }, + ], + wgsl: BODY.to_string(), + }) + .collect() + } +} + +/// The pass emitted when the radius is finer than a render pixel. +/// +/// One dispatch that changes nothing, rather than an empty chain, and the +/// difference is not stylistic. [`crate::operation::compose_full`] decides +/// from the *operations* — before any resolution is known — that an active +/// detail operation means the fused pass hands on unclipped linear values +/// instead of encoding its own output. If this returned no passes at all, +/// that decision would still stand and nothing downstream would ever perform +/// the output transform: `dr-gpu` would be handed a linear-working shader +/// with an empty chain and refuse it. +/// +/// So the honest "nothing survives at this scale" still has to carry the +/// encode, and one pass that does only that is exactly the resolve step the +/// stage would otherwise need. It costs a single copy of a proxy-sized +/// texture, which is a rounding error against the dispatches around it. +fn nothing_to_sharpen() -> DetailPass { + DetailPass { + label: "unresolved", + // Reads only the pixel it writes, so a tile needs no halo at all. + radius: 0, + uniforms: Vec::new(), + wgsl: "// The chosen radius is finer than one pixel of this render, so the detail +// it would act on is not in this texture — it was lost to the downscale +// before this stage ran (FR-DSP-1). Guessing at it would put sharpening on +// screen that the exported file will not contain, so this pass passes the +// colour through unchanged and the interface is free to say `zoom to 1:1`. +// +// `c` already holds this pixel; leaving it alone is the whole body." + .to_string(), + } +} + +/// One axis of the separable unsharp mask. +/// +/// Emitted verbatim for both passes — see [`DetailStage::passes`] for why the +/// axis is read from a uniform the composer already writes rather than from a +/// second copy of this kernel. +const BODY: &str = r#"// One axis of a separable unsharp mask, applied to luminance. +// +// The composer writes this pass's index into the base uniform block's fourth +// lane precisely so that a two-pass operation need not carry a uniform of its +// own to say which half it is in. Pass 0 walks x, pass 1 walks y. +let axis = select(vec2(0, 1), vec2(1, 0), u.detail_base.w < 0.5); + +// The Gaussian is evaluated here rather than uploaded as a weight table. The +// kernel changes size with the zoom — the radius is in source pixels and the +// ratio is not fixed — so a table would have to be a fixed-length array padded +// to the widest kernel the slider can reach, uploaded per frame, to save an +// `exp` that the hardware does in one instruction. +let extent = i32(taps); +let falloff = 1.0 / (2.0 * sigma * sigma); + +// Sharpening acts on luminance alone. Adding the high-pass to the three +// channels independently sharpens chroma noise into coloured speckle at every +// edge, which is the classic way an unsharp mask ruins a high-ISO frame; and +// the demosaic's interpolation error — the thing being corrected — is a +// luminance error, because that is the channel the CFA samples most densely. +let centre = luminance(c); + +var weighted = 0.0; +var total = 0.0; +for (var i = -extent; i <= extent; i = i + 1) { + let d = f32(i); + let w = exp(-d * d * falloff); + weighted = weighted + w * luminance(tap(coord, axis * i)); + total = total + w; +} + +// Normalised by the weights actually summed, never by an analytic integral. +// The kernel is truncated at three sigma and `tap` clamps at the border, so +// the two disagree — by a fraction of a percent in the middle of the frame and +// by far more along its edge. Dividing by the wrong one would put a bright or +// dark band around the whole photograph, which is invisible on a test pattern +// and perfectly visible on a sky. +let blurred = weighted / total; + +// Everything this axis's blur threw away. Zero on a flat field, so a sky comes +// through untouched at any amount, and the kernel as a whole still sums to one. +let high = centre - blurred; + +// The threshold, as *local contrast* rather than as an absolute difference. +// +// A photographer setting this is saying "modulation this shallow is noise, not +// detail", and that judgement is about the ratio between the detail and the +// tone it sits on: the same sensor noise is a hundred times smaller in linear +// units in a shadow than in a highlight, so an absolute gate calibrated on a +// midtone would leave shadow noise fully sharpened and flatten highlight +// texture. A ratio also makes the control survive the exposure slider, which +// an absolute one would not. +// +// The floor keeps the ratio finite as the local level approaches black. Below +// roughly nine stops down there is nothing but read noise anyway, and without +// it a noise-sized difference divided by a noise-sized level would read as a +// hard edge and be sharpened hardest exactly where it is least wanted. +let level = max(blurred, 0.005); +let contrast = abs(high) / level; + +// A soft knee rather than a step: gating on a comparison would sharpen one +// pixel fully and its neighbour not at all, and the boundary between them is +// itself an edge — visible as a crawling outline around every gently graded +// region. Full suppression below half the gate, full effect above it. +let knee = max(gate, 1e-5); +let keep = select(1.0, smoothstep(knee * 0.5, knee, contrast), gate > 0.0); + +let target = centre + amount * high * keep; + +// Applied as a gain on all three channels rather than as an offset, so that +// steepening an edge does not drag its colour towards grey: the channel ratios +// are the hue and the saturation, and multiplying leaves them exactly where +// they were. Negative luminance is not a colour, so undershoot stops at black +// — the only clamp in this stage, and it is on the scalar, not on the channels, +// which stay unclipped above one for the output transform to deal with. +// +// Below a nearly-black luminance the ratio stops carrying information — the +// three channels are all noise there and the divisor is meaningless — so the +// pixel is handed on untouched rather than multiplied by whatever fell out. +let scaled = c * (max(target, 0.0) / max(centre, 1e-5)); +c = select(c, scaled, centre > 1e-5);"#; + +#[cfg(test)] +mod tests { + use super::*; + use crate::detail::compose_detail; + use dr_types::ColourSpace; + + /// The chain with the sharpener turned up, as the composer sees it. + fn sharpening(amount: f32, radius: f32) -> Vec> { + let mut ops = crate::ops::chain(); + for op in &mut ops { + if op.descriptor().id == ID { + op.set_param(AMOUNT, amount); + op.set_param(RADIUS, radius); + } + } + ops + } + + fn chain_at(ops: &[Box], scale: RenderScale) -> crate::detail::ComposedDetail { + compose_detail(ops, scale, ColourSpace::Srgb) + } + + #[test] + fn a_radius_alone_is_not_an_edit() { + // The neutral rule this operation states differently from most: two of + // its three parameters describe *how* to sharpen, and moving them with + // the amount at zero must leave the file unmodified. Otherwise opening + // an image and brushing the radius slider would mark it edited and + // write a sidecar for a photograph nobody changed. + let mut op = CaptureSharpen::new(); + assert!(!op.is_active()); + op.set_param(RADIUS, 3.0); + op.set_param(THRESHOLD, 1.0); + assert!(!op.is_active(), "a radius is not a decision to sharpen"); + op.set_param(AMOUNT, 25.0); + assert!(op.is_active()); + } + + #[test] + fn a_neutral_sharpener_composes_no_passes_at_all() { + // The rule the whole pipeline rests on: an operation at its defaults + // costs nothing. Almost every photograph in a library is unsharpened, + // and none of them should pay a dispatch for it. + let composed = chain_at(&crate::ops::chain(), RenderScale::full((512, 512))); + assert!(composed.is_empty()); + assert_eq!(composed.radius(), 0); + } + + #[test] + fn sharpening_is_two_passes_and_only_the_last_one_encodes() { + // Separability, as it reaches the GPU. The first pass writes a linear + // intermediate and the second writes the display texture, so the + // output transform happens exactly once (FR-DEV-2) at the end of the + // chain rather than in the middle of a convolution. + let composed = chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))); + assert_eq!(composed.len(), 2); + + let (first, last) = (&composed.passes[0], &composed.passes[1]); + assert_eq!(first.label, "capture_sharpen/horizontal"); + assert_eq!(last.label, "capture_sharpen/vertical"); + + assert!(!first.writes_output); + assert!(first.source.contains("texture_storage_2d f32 { + let scale = RenderScale::new((render, render), full); + chain_at(&ops, scale).radius() as f32 / scale.ratio() + }; + + // Export, half-size proxy, quarter-size proxy. + let export = in_source_pixels(4000); + let half = in_source_pixels(2000); + let quarter = in_source_pixels(1000); + + assert!((export - 12.0).abs() < 0.01, "three sigma of four pixels"); + // Within the rounding of one render pixel back through the ratio, + // which is the whole of the permitted error: the kernel is an integer + // count of render pixels and 12 does not divide evenly by four. + assert!( + (half - export).abs() <= 2.0, + "the same edit covers {half} source pixels on a half proxy and \ + {export} at export" + ); + assert!( + (quarter - export).abs() <= 4.0, + "the same edit covers {quarter} source pixels on a quarter proxy \ + and {export} at export" + ); + + // The other half of the statement, and the one that fails if the unit + // is misread: the kernel in *render* pixels must shrink with the + // render, because that is what keeps it the same size on the picture. + let render_pixels = |render: u32| { + chain_at(&ops, RenderScale::new((render, render), full)).radius() + }; + assert_eq!(render_pixels(4000), 12); + assert_eq!(render_pixels(2000), 6); + assert_eq!(render_pixels(1000), 3); + } + + #[test] + fn a_render_too_coarse_for_the_radius_stops_rather_than_guesses() { + // A one-pixel radius on a quarter-scale proxy is a quarter of a render + // pixel, and no kernel represents that — the frequencies it would act + // on went out with the downscale. `RenderScale::resolves` reports the + // condition and this obeys it, because a preview that shows sharpening + // the exported file will not contain is worse than one that shows none. + let ops = sharpening(100.0, 1.0); + let proxy = RenderScale::new((1000, 1000), (4000, 4000)); + assert!(!proxy.resolves(1.0)); + + let composed = chain_at(&ops, proxy); + // Not empty, though. See `nothing_to_sharpen`: the fused pass has + // already been composed to hand on linear values, so *something* must + // still perform the output transform. + assert_eq!(composed.len(), 1); + assert_eq!(composed.radius(), 0, "it reads no neighbours"); + let pass = &composed.passes[0]; + assert_eq!(pass.label, "capture_sharpen/unresolved"); + assert!(pass.writes_output); + assert!(pass.source.contains("fn encode_output")); + assert!( + !pass.source.contains("for (var i ="), + "the pass-through must not walk a kernel it has decided not to run" + ); + + // Zooming to 1:1 is what brings it back — the view rect shrinks while + // the render target keeps its size — so there is no separate + // full-resolution preview path for a photographer to wait on. + let one_to_one = RenderScale::new((1000, 1000), (1000, 1000)); + assert_eq!(chain_at(&ops, one_to_one).len(), 2); + } + + #[test] + fn the_amount_reaches_the_shader_as_a_gain_and_keeps_its_sign() { + // Negative is not a mistake to be clamped away: a lens that + // out-resolves the sensor, or a frame with moiré, wants the capture + // chain's acutance backed off rather than lifted, and the same kernel + // with a negative gain is exactly that. + let amount_of = |value: f32| -> f32 { + let composed = chain_at(&sharpening(value, 1.0), RenderScale::full((512, 512))); + // Base block first and fixed, then this pass's own, in the order + // `passes` declared them. + composed.passes[0].uniforms[crate::detail::DETAIL_BASE_UNIFORM_FIELDS] + }; + assert!((amount_of(100.0) - 1.0).abs() < 1e-6); + assert!((amount_of(50.0) - 0.5).abs() < 1e-6); + assert!((amount_of(-40.0) + 0.4).abs() < 1e-6); + } + + #[test] + fn the_threshold_is_off_when_it_is_at_zero() { + // The gate is a `smoothstep`, and a `smoothstep` whose two edges meet + // is undefined. The body guards it with a `select` on this uniform + // being positive, so a photographer who never touches the threshold + // gets the plain unsharp mask and not a NaN. + let gate_of = |threshold: f32| -> f32 { + let mut ops = sharpening(50.0, 1.0); + for op in &mut ops { + if op.descriptor().id == ID { + op.set_param(THRESHOLD, threshold); + } + } + let composed = chain_at(&ops, RenderScale::full((512, 512))); + *composed.passes[0].uniforms.last().expect("a gate") + }; + assert_eq!(gate_of(0.0), 0.0); + assert!((gate_of(1.0) - 0.25).abs() < 1e-6); + assert!(chain_at(&sharpening(50.0, 1.0), RenderScale::full((512, 512))).passes[0] + .source + .contains("gate > 0.0")); + } + + #[test] + fn every_pass_declares_a_uniform_block_the_gpu_will_accept() { + // A uniform struct whose size is not a multiple of sixteen is rejected + // outright by the WGSL uniform address space rules, and the failure + // arrives as a compilation error against source nobody wrote. Both + // shapes this operation emits have to satisfy it — the sharpening pair + // and the pass-through, which carries no uniforms of its own at all. + let scales = [ + RenderScale::full((512, 512)), + RenderScale::new((256, 256), (4096, 4096)), + ]; + for scale in scales { + for pass in chain_at(&sharpening(75.0, 1.0), scale).passes { + assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label); + assert!(pass.uniforms.iter().all(|v| v.is_finite()), "{}", pass.label); + } + } + } + + #[test] + fn a_deep_zoom_cannot_turn_a_slider_into_an_unbounded_convolution() { + // Zooming past about 16:1 pushes the ratio above one, and a radius in + // source pixels becomes many render pixels. The cap keeps the cost of + // a magnification nobody judges sharpening at from growing without + // limit; what it costs is a Gaussian truncated inside three sigma, + // which is a slightly tighter blur and no other artefact. + let mut op = CaptureSharpen::new(); + op.set_param(AMOUNT, 100.0); + op.set_param(RADIUS, 3.0); + let deep = RenderScale::new((2000, 2000), (25, 25)); + assert!(deep.ratio() > 16.0); + assert_eq!(op.kernel(deep), MAX_KERNEL as u32); + } +} diff --git a/core/dr-pipeline/src/ops/mod.rs b/core/dr-pipeline/src/ops/mod.rs index 34362cb..413bce3 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -22,10 +22,20 @@ //! reason rather than for want of migrating. The tone curve interpolates //! between five points and its neutral is a *relationship* between them; the //! colour mixer generates thirty-six faceted parameters from twelve computed -//! hue bands; [`vignetting`] carries lens-profile coefficients that are not +//! hue bands; [`capture_sharpen`] is a convolution, and the schema describes a +//! fragment handed a colour with no way back to a coordinate; +//! [`vignetting`] carries lens-profile coefficients that are not //! parameters at all. A schema stretched to cover those would be a worse //! language than Rust, aimed at one caller each. //! +//! # The neighbourhood nodes +//! +//! [`capture_sharpen`] reads the pixels around the one it writes, so it runs +//! in [`crate::detail`]'s stage after the fused pass rather than as a fragment +//! within it. It is an ordinary [`Operation`](crate::Operation) in every other +//! respect — descriptor, parameters, sidecar, history — which is what lets the +//! panel, the presets and the undo stack carry it with no special case. +//! //! Both publish the same [`crate::descriptor::OpDescriptor`], so nothing //! downstream can tell them apart. A hand-written node still declares its //! place in the chain in `ops/.yaml` with `rust:`, so the directory @@ -41,12 +51,14 @@ // Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what // places it in the chain; these are the implementations that entry points at. pub mod aberration; +pub mod capture_sharpen; pub mod colour_mixer; pub mod curve; pub mod distortion; pub mod vignetting; pub use aberration::Aberration; +pub use capture_sharpen::CaptureSharpen; pub use colour_mixer::ColourMixer; pub use curve::ToneCurve; pub use distortion::Distortion; diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index 4d2f242..8c0e2c6 100644 --- a/ui/dr-ui/src/labels.rs +++ b/ui/dr-ui/src/labels.rs @@ -30,6 +30,13 @@ pub fn resolve(key: &str) -> String { "op.vibrance" => "Vibrance".into(), "op.saturation" => "Saturation".into(), "op.colour_mixer" => "Colour Mixer".into(), + // "Sharpening" rather than what `derive` would make of the id. The id + // says *capture* sharpening to separate it from the output sharpening + // an export applies (FR-EXP-4), which is a distinction about where in + // the pipeline it sits; in the develop panel there is only one, and + // "Capture Sharpen" would name a distinction the photographer cannot + // see from there. + "op.capture_sharpen" => "Sharpening".into(), "op.framing" => "Crop & Rotate".into(), // Parameters @@ -121,6 +128,16 @@ mod tests { fn catalogued_keys_resolve_to_their_label() { assert_eq!(resolve("op.white_balance"), "White Balance"); assert_eq!(resolve("param.highlights"), "Highlights"); + // Catalogued precisely because `derive` would get it wrong: the id + // carries a distinction ("capture", as against an export's output + // sharpening) that belongs in the pipeline and not on a panel. + assert_eq!(resolve("op.capture_sharpen"), "Sharpening"); + // Its parameters are the opposite case — the derived words are the + // right words, so they are left uncatalogued and shared with whatever + // asks for an amount or a radius next. + assert_eq!(resolve("param.amount"), "Amount"); + assert_eq!(resolve("param.radius"), "Radius"); + assert_eq!(resolve("param.threshold"), "Threshold"); } #[test] From b4e55b47c11cd456ed4ae04a75837d19f72bf8ac Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:01:18 +0200 Subject: [PATCH 07/27] WIP: noise reduction Checkpoint committed by the coordinator, not by the authoring agent: the session hit its API limit mid-task and left this work uncommitted. Committed so it survives, NOT because it is finished - expect failing tests and half-applied changes. The agent resumes from here. --- core/dr-gpu/tests/noise_reduction.rs | 471 ++++++++++ core/dr-pipeline/ops/README.md | 4 +- core/dr-pipeline/ops/noise_reduction.yaml | 30 + core/dr-pipeline/src/ops/mod.rs | 8 + core/dr-pipeline/src/ops/noise_reduction.rs | 912 ++++++++++++++++++++ ui/dr-ui/src/develop.rs | 49 +- 6 files changed, 1469 insertions(+), 5 deletions(-) create mode 100644 core/dr-gpu/tests/noise_reduction.rs create mode 100644 core/dr-pipeline/ops/noise_reduction.yaml create mode 100644 core/dr-pipeline/src/ops/noise_reduction.rs diff --git a/core/dr-gpu/tests/noise_reduction.rs b/core/dr-gpu/tests/noise_reduction.rs new file mode 100644 index 0000000..92ba008 --- /dev/null +++ b/core/dr-gpu/tests/noise_reduction.rs @@ -0,0 +1,471 @@ +//! Noise reduction, end to end on a real device. +//! +//! `dr-pipeline`'s own tests assert what the operation *composes* — how many +//! passes, what radius, in what unit. None of them can tell whether the WGSL +//! compiles, whether the filter actually preserves an edge, or whether the +//! luminance and chroma halves stay out of each other's way once real floats +//! run through them. Those are questions only a GPU answers. +//! +//! # Reading the expected values +//! +//! Sources are uploaded through `DemosaicedImage::from_rgba8`, which flags +//! them non-linear, so the generated shader decodes sRGB before any operation +//! runs and the detail stage sees linear values. The last detail pass +//! re-encodes. So every assertion here decodes the readback back to linear +//! before comparing — comparing 8-bit code values directly would fold the +//! transfer function's varying slope into every tolerance. +//! +//! Almost everything is measured **against a baseline render of the same +//! image with the amount at zero**, rather than against an absolute +//! expectation. That is deliberate: it isolates what noise reduction did from +//! everything else the pipeline does to a pixel, and it stays correct if a +//! later change to the chain moves the values this stage is handed. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::detail::RenderScale; +use dr_pipeline::ops::noise_reduction::{CHROMA, ID, LUMINANCE}; +use dr_pipeline::{Affects, EditGraph}; +use dr_types::ColourSpace; + +fn ctx() -> Option { + // CI runners and headless machines may have no usable adapter. Skip rather + // than fail, exactly as the rest of this crate's device tests do. + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +fn graph_with(luminance: f32, chroma: f32) -> EditGraph { + let mut graph = EditGraph::default_chain(); + graph.set_param(ID, LUMINANCE, luminance); + graph.set_param(ID, CHROMA, chroma); + graph +} + +/// Render one graph and read the pixels back, at the scale the graph itself +/// works out — which is what a frontend does. +fn render( + ctx: &GpuContext, + pass: &mut AdjustPass, + graph: &EditGraph, + source: &DemosaicedImage, + out: (u32, u32), +) -> Vec { + let scale = graph.render_scale(source.size(), out); + render_at(ctx, pass, graph, source, out, scale) +} + +/// Render with an explicitly chosen [`RenderScale`]. +/// +/// Split out for one test only — the one that needs to compose the detail +/// stage at the *wrong* scale on purpose, to show that the conversion from +/// source pixels to render pixels is load-bearing rather than decorative. +fn render_at( + _ctx: &GpuContext, + pass: &mut AdjustPass, + graph: &EditGraph, + source: &DemosaicedImage, + out: (u32, u32), + scale: RenderScale, +) -> Vec { + let shader = graph.compose_for(ColourSpace::Srgb); + let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let key = graph.invalidation().through(Affects::Colour); + pass.render_detailed(source, &shader, out.0, out.1, None, &detail, key) + .expect("render"); + pass.export_pixels().expect("readback").0 +} + +fn srgb_decode(byte: u8) -> f32 { + let e = byte as f32 / 255.0; + if e <= 0.040_45 { + e / 12.92 + } else { + ((e + 0.055) / 1.055).powf(2.4) + } +} + +fn luminance(c: [f32; 3]) -> f32 { + 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] +} + +/// One pixel of a readback, as linear RGB. +fn linear(pixels: &[u8], width: u32, x: u32, y: u32) -> [f32; 3] { + let i = ((y * width + x) * 4) as usize; + [ + srgb_decode(pixels[i]), + srgb_decode(pixels[i + 1]), + srgb_decode(pixels[i + 2]), + ] +} + +/// A pixel split the way the operation itself splits it: a luminance, and a +/// colour difference whose own luminance is zero. +fn split(pixels: &[u8], width: u32, x: u32, y: u32) -> (f32, [f32; 3]) { + let c = linear(pixels, width, x, y); + let y0 = luminance(c); + (y0, [c[0] - y0, c[1] - y0, c[2] - y0]) +} + +/// Upload an image built from a per-pixel closure. +fn upload( + ctx: &GpuContext, + size: u32, + f: impl Fn(u32, u32) -> [u8; 3], +) -> DemosaicedImage { + let data: Vec = (0..size * size) + .flat_map(|i| { + let (x, y) = (i % size, i / size); + let [r, g, b] = f(x, y); + [r, g, b, 255] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") +} + +/// A vertical step edge of a chosen height, centred on the frame. +fn step_edge(ctx: &GpuContext, size: u32, low: u8, high: u8) -> DemosaicedImage { + upload(ctx, size, move |x, _| { + let v = if x < size / 2 { low } else { high }; + [v, v, v] + }) +} + +#[test] +fn a_difference_below_the_threshold_is_averaged_and_one_above_it_is_not() { + // The defining property, and the reason this is a bilateral rather than a + // Gaussian. Both images are step edges and the filter is identical; the + // only thing that differs is how tall the step is relative to the noise + // threshold. A Gaussian would soften both by exactly the same amount, and + // that indiscriminate softening is what "denoised" pictures look like. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 64; + let mid = SIZE / 2; + let row = SIZE / 2; + + let measure = |source: &DemosaicedImage| -> f32 { + let mut off = AdjustPass::new(&ctx); + let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), source, (SIZE, SIZE)); + let mut on = AdjustPass::new(&ctx); + let denoised = render(&ctx, &mut on, &graph_with(100.0, 0.0), source, (SIZE, SIZE)); + // How far the pixel just inside the bright side moved, in linear + // luminance. An averaging filter pulls it down towards the dark half; + // an edge-preserving one leaves it where it was. + let (before, _) = split(&plain, SIZE, mid, row); + let (after, _) = split(&denoised, SIZE, mid, row); + before - after + }; + + // A step of eight code values around mid-grey is about 0.028 in linear + // luminance, against a threshold of roughly 0.034 at full amount: within + // the range where the filter is meant to treat a difference as noise. + let quiet = measure(&step_edge(&ctx, SIZE, 120, 128)); + assert!( + quiet > 0.004, + "a difference below the threshold was left alone: moved {quiet}" + ); + + // Black to white is thirty times the threshold. It has to survive intact + // — an edge that softens here is a halo in every high-contrast picture. + let loud = measure(&step_edge(&ctx, SIZE, 0, 255)); + assert!( + loud.abs() < 0.004, + "an edge far above the threshold was smoothed: moved {loud}" + ); + assert!( + quiet > loud.abs() * 3.0, + "the filter did not distinguish noise from an edge: {quiet} vs {loud}" + ); +} + +/// A fine chroma pattern on a constant grey: colour that alternates every +/// `half_period` pixels with the lightness very nearly fixed. +/// +/// This is what chroma noise looks like to the filter — a colour difference +/// with almost no luminance difference under it — and it is the one pattern +/// that can tell the two halves of this operation apart. +fn chroma_pattern(ctx: &GpuContext, size: u32, half_period: u32, swing: i32) -> DemosaicedImage { + upload(ctx, size, move |x, _| { + let on = (x / half_period) % 2 == 0; + let d = if on { swing } else { -swing }; + [(128 + d) as u8, 128, (128 - d) as u8] + }) +} + +/// How much of a known alternating pattern survived, as the correlation of one +/// linear channel of the middle row against the pattern's own sign. +/// +/// A matched filter rather than a peak-to-peak reading. The readback is eight +/// bits, and the modulation these tests work with is only a handful of code +/// values — deliberately, because a larger one would read as a real colour +/// boundary and the filter would refuse to touch it. Correlating over a whole +/// number of periods averages the quantisation down instead of letting it set +/// the noise floor of the measurement. +/// +/// `margin` covers a whole number of periods too, so the window is unbiased by +/// the row's mean, and it keeps the measurement clear of the borders where a +/// clamped kernel legitimately behaves differently. +fn modulation(pixels: &[u8], width: u32, half_period: u32, channel: usize) -> f32 { + let row = width / 2; + let margin = half_period * 4; + let mut total = 0.0; + let mut count = 0.0; + for x in margin..(width - margin) { + let sample = match channel { + usize::MAX => luminance(linear(pixels, width, x, row)), + c => linear(pixels, width, x, row)[c], + }; + let sign = if (x / half_period) % 2 == 0 { 1.0 } else { -1.0 }; + total += sample * sign; + count += 1.0; + } + total / count +} + +/// Correlate against luminance rather than a channel. +const AS_LUMINANCE: usize = usize::MAX; + +#[test] +fn chroma_noise_reduction_never_moves_lightness() { + // Half of the claim the two-slider design rests on. The split is into a + // luminance and a colour difference whose own luminance is zero, so the + // chroma passes reconstruct with the lightness this pixel arrived with — + // exactly, not approximately. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 64; + + // The strongest available form of the assertion, on an image that has no + // colour to filter: every colour difference is zero, so the filter is the + // identity and the output must be the *same bytes*. A reconstruction that + // used a filtered luminance instead of this pixel's own would soften the + // step and show up here immediately. + let grey = step_edge(&ctx, SIZE, 90, 110); + let mut off = AdjustPass::new(&ctx); + let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &grey, (SIZE, SIZE)); + let mut on = AdjustPass::new(&ctx); + let denoised = render(&ctx, &mut on, &graph_with(0.0, 100.0), &grey, (SIZE, SIZE)); + assert_eq!( + plain, denoised, + "chroma noise reduction altered an image with no colour in it" + ); + + // And on an image that does have colour to filter, where the two halves + // could actually interfere: the colour modulation must fall while the + // luminance modulation under it stays where it was. The tolerance is wide + // because both readings pass through an eight-bit readback twice over; the + // failure it guards against is not a drift of a few percent but a + // collapse, which is what a leak between the two components would be. + let source = chroma_pattern(&ctx, SIZE, 4, 12); + let mut off = AdjustPass::new(&ctx); + let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE)); + let mut on = AdjustPass::new(&ctx); + let chroma = render(&ctx, &mut on, &graph_with(0.0, 100.0), &source, (SIZE, SIZE)); + + let colour_before = modulation(&plain, SIZE, 4, 0); + let colour_after = modulation(&chroma, SIZE, 4, 0); + assert!( + colour_after < colour_before * 0.7, + "chroma noise reduction did not reduce the colour swing: \ + {colour_after} of {colour_before}" + ); + + let light_before = modulation(&plain, SIZE, 4, AS_LUMINANCE); + let light_after = modulation(&chroma, SIZE, 4, AS_LUMINANCE); + assert!( + (light_after - light_before).abs() < light_before.abs() * 0.3, + "chroma noise reduction moved lightness: {light_after} was {light_before}" + ); +} + +#[test] +fn luminance_noise_reduction_never_moves_colour() { + // The other half. The luminance pass adds the *change* in lightness back + // to the colour it was given, so the colour difference passes through + // untouched however hard the luminance is filtered. Written the obvious + // way instead — filtering the three channels and calling it a luminance + // filter — the colour would desaturate as the amount rose, and the chroma + // slider would stop meaning anything on its own. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 64; + let source = chroma_pattern(&ctx, SIZE, 4, 12); + + let mut off = AdjustPass::new(&ctx); + let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE)); + let mut on = AdjustPass::new(&ctx); + let luma = render(&ctx, &mut on, &graph_with(100.0, 0.0), &source, (SIZE, SIZE)); + + // The colour difference — not the raw channel, which follows lightness. + let row = SIZE / 2; + let interior = 16..(SIZE - 16); + let mut worst = 0.0f32; + for x in interior { + let (_, before) = split(&plain, SIZE, x, row); + let (_, after) = split(&luma, SIZE, x, row); + worst = worst.max((after[0] - before[0]).abs()); + } + // A code value at this brightness, doubled for the two readbacks the + // comparison passes through. The leak this guards against would be a + // sizeable fraction of the pattern's own 0.037 swing, not a rounding. + let quantum = srgb_decode(129) - srgb_decode(128); + assert!( + worst < quantum * 3.0, + "luminance noise reduction moved colour by {worst} \ + (one code value is {quantum})" + ); +} + +#[test] +fn the_same_edit_denoises_the_same_at_two_resolutions() { + // TRACES: FR-DSP-1 — the thing this operation is most likely to get wrong. + // + // A radius is stored in *source* pixels and converted to render pixels at + // every render, because noise is made by photosites. Get that conversion + // wrong and the develop view and the exported file are different + // photographs: tune the slider on a half-size proxy and the export is + // denoised at half the strength, or twice it. + // + // The subject is a chroma square wave with a period that is a power of two + // and aligned to the frame, so halving the render resolution decimates it + // exactly — the proxy sees the same pattern at half the period, with no + // resampling of its own to confuse the measurement. + let Some(ctx) = ctx() else { return }; + const SOURCE: u32 = 256; + const HALF_PERIOD: u32 = 8; // in source pixels + // Six code values of swing. Small on purpose: the colour difference has to + // land near the filter's threshold, because a larger one is a colour + // boundary and the whole point of a bilateral is that it refuses to cross + // those. There would be nothing to measure at either resolution. + let source = chroma_pattern(&ctx, SOURCE, HALF_PERIOD, 6); + + // Sixty percent is an eight-source-pixel radius, which halves to exactly + // four render pixels on a half-size proxy — so the rounding to an integer + // kernel is not what this test is measuring. + let graph = graph_with(0.0, 60.0); + + let mut export_pass = AdjustPass::new(&ctx); + let export = render(&ctx, &mut export_pass, &graph, &source, (SOURCE, SOURCE)); + let export_amp = modulation(&export, SOURCE, HALF_PERIOD, 0); + + let proxy_size = SOURCE / 2; + let mut proxy_pass = AdjustPass::new(&ctx); + let proxy = render(&ctx, &mut proxy_pass, &graph, &source, (proxy_size, proxy_size)); + let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, 0); + + // Both must be doing something: two flat images would agree perfectly and + // prove nothing. + let untouched = { + let mut pass = AdjustPass::new(&ctx); + let plain = render(&ctx, &mut pass, &graph_with(0.0, 0.0), &source, (SOURCE, SOURCE)); + modulation(&plain, SOURCE, HALF_PERIOD, 0) + }; + assert!( + export_amp < untouched * 0.8, + "the denoiser did nothing: {export_amp} of {untouched}" + ); + + assert!( + (proxy_amp - export_amp).abs() < export_amp * 0.2, + "the same edit left {proxy_amp} of the pattern on the proxy and \ + {export_amp} on the export" + ); + + // The control, and the reason the tolerance above means something. Compose + // the detail stage as though the proxy were a full-resolution render — + // which is exactly the bug of storing a radius in render pixels — and the + // kernel is twice as wide in source terms. If the conversion were not + // load-bearing, this would land in the same place as the other two. + let mut wrong_pass = AdjustPass::new(&ctx); + let wrong = render_at( + &ctx, + &mut wrong_pass, + &graph, + &source, + (proxy_size, proxy_size), + RenderScale::full((proxy_size, proxy_size)), + ); + let wrong_amp = modulation(&wrong, proxy_size, HALF_PERIOD / 2, 0); + assert!( + wrong_amp < export_amp * 0.7, + "an unconverted radius was indistinguishable from a converted one: \ + {wrong_amp} against {export_amp}" + ); +} + +#[test] +fn each_amount_costs_only_the_dispatches_it_needs() { + // The cost story, which is invisible in the picture and therefore has to + // be asserted on a counter. Luminance is one exact two-dimensional pass; + // chroma is two, because at its radius the exact form is quadratic and + // unaffordable. An edit using neither must pay for neither — and must + // produce pixels identical to a chain that has no denoiser in it at all. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 48; + let source = step_edge(&ctx, SIZE, 40, 200); + + for (luminance, chroma, expected) in [(60.0, 0.0, 1), (0.0, 60.0, 2), (60.0, 60.0, 3)] { + let mut pass = AdjustPass::new(&ctx); + render(&ctx, &mut pass, &graph_with(luminance, chroma), &source, (SIZE, SIZE)); + assert_eq!( + pass.detail_dispatches(), + expected, + "luminance {luminance}, chroma {chroma}" + ); + assert_eq!(pass.colour_dispatches(), 1); + } + + let mut neutral = AdjustPass::new(&ctx); + let a = render(&ctx, &mut neutral, &graph_with(0.0, 0.0), &source, (SIZE, SIZE)); + assert_eq!(neutral.detail_dispatches(), 0); + assert_eq!(neutral.detail_allocations(), 0, "nothing was allocated"); + + // Byte-identical, not merely close: an operation at its defaults must not + // touch the image, and a stage that ran and wrote back the same values + // would still have quantised twice. + let mut absent = AdjustPass::new(&ctx); + let b = render(&ctx, &mut absent, &EditGraph::default_chain(), &source, (SIZE, SIZE)); + assert_eq!(a, b, "a neutral denoiser changed the picture"); +} + +#[test] +fn dragging_either_slider_recompiles_nothing_and_reallocates_nothing() { + // TRACES: FR-DEV-3d. Both of these are ruinous per frame and invisible in + // the output, which is why they need a counter rather than an eye. A + // radius rides in a uniform buffer, so moving a slider re-runs the detail + // dispatches against the pipelines already compiled — and does not re-run + // the colour pass at all, since nothing it depends on moved. + let Some(ctx) = ctx() else { return }; + const SIZE: u32 = 48; + let source = step_edge(&ctx, SIZE, 40, 200); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = graph_with(50.0, 50.0); + render(&ctx, &mut pass, &graph, &source, (SIZE, SIZE)); + let pipelines = pass.cached_detail_pipelines(); + let allocations = pass.detail_allocations(); + assert_eq!(pipelines, 3, "one per pass: luminance, then two for chroma"); + + for amount in [55.0, 60.0, 65.0, 70.0] { + graph.set_param(ID, LUMINANCE, amount); + graph.set_param(ID, CHROMA, amount); + render(&ctx, &mut pass, &graph, &source, (SIZE, SIZE)); + } + assert_eq!( + pass.cached_detail_pipelines(), + pipelines, + "an amount is a uniform, not a shader" + ); + assert_eq!( + pass.detail_allocations(), + allocations, + "a steady viewport must allocate nothing" + ); + assert_eq!( + pass.colour_dispatches(), + 1, + "the fused colour pass re-ran for a change it does not depend on" + ); +} diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index dfb8ec8..8128817 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -211,7 +211,9 @@ half in Rust would be worse than either alone. Currently hand-written: `tone_curve` (a curve widget over five interpolated points), `colour_mixer` (thirty-six faceted parameters from twelve computed hue -bands). `vignetting` is hand-written too but is not in the develop chain — it +bands), and `noise_reduction` (a kernel, and one that decides how many +dispatches to emit at each resolution — see the next section). +`vignetting` is hand-written too but is not in the develop chain — it carries lens-profile coefficients that are not parameters. `distortion` and `aberration` are `Warp`s rather than operations: they rewrite coordinates before sampling rather than transforming a colour after it. diff --git a/core/dr-pipeline/ops/noise_reduction.yaml b/core/dr-pipeline/ops/noise_reduction.yaml new file mode 100644 index 0000000..20722f0 --- /dev/null +++ b/core/dr-pipeline/ops/noise_reduction.yaml @@ -0,0 +1,30 @@ +# A hand-written node. `rust:` names the type in `crate::ops` that implements +# `Operation`; its descriptor, its parameters and its passes come from that +# type rather than from this file. +# +# It appears here anyway so that `ops/` lists the whole pipeline in order — +# including the neighbourhood operations, which run as a group after every +# point operation but are still ordered among themselves. +id: noise_reduction +order: 110 + +rust: NoiseReduction + +why_rust: | + A kernel, not four facts. The declarative schema hands a fragment a colour + and no coordinate, which is precisely what a denoiser cannot work with — it + is defined by what the neighbouring pixels are doing. It is therefore a + `DetailStage` (see `../src/detail.rs`), which means deciding at every render + how many dispatches to emit, converting a radius stated in *source* pixels + into the render pixels this frame is being drawn at, and declaring the halo + the tile scheduler needs. None of that is expressible as a uniform + expression, and stretching the schema to cover it would produce a worse + language than Rust aimed at one caller. + +placement: | + First among the detail operations, because denoising is a repair and + everything else in this stage is an enhancement: sharpening or adding + clarity to a noisy frame amplifies the grain along with the detail, and no + later pass can separate them again. Its position relative to the point + operations is not this number's to decide — the whole detail stage runs + after the fused pass, in linear light, before the output transform. diff --git a/core/dr-pipeline/src/ops/mod.rs b/core/dr-pipeline/src/ops/mod.rs index 34362cb..2edb12f 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -26,6 +26,12 @@ //! parameters at all. A schema stretched to cover those would be a worse //! language than Rust, aimed at one caller each. //! +//! [`noise_reduction`] is an exception for a different reason again: it is a +//! *kernel*, and a declared node's `wgsl:` is handed a colour with no way back +//! to a coordinate. It runs in [`crate::detail`] instead, deciding at each +//! render how many dispatches to emit and converting a radius stated in sensor +//! pixels into the render pixels this frame is being drawn at. +//! //! Both publish the same [`crate::descriptor::OpDescriptor`], so nothing //! downstream can tell them apart. A hand-written node still declares its //! place in the chain in `ops/.yaml` with `rust:`, so the directory @@ -44,12 +50,14 @@ pub mod aberration; pub mod colour_mixer; pub mod curve; pub mod distortion; +pub mod noise_reduction; pub mod vignetting; pub use aberration::Aberration; pub use colour_mixer::ColourMixer; pub use curve::ToneCurve; pub use distortion::Distortion; +pub use noise_reduction::NoiseReduction; pub use vignetting::Vignetting; // The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by diff --git a/core/dr-pipeline/src/ops/noise_reduction.rs b/core/dr-pipeline/src/ops/noise_reduction.rs new file mode 100644 index 0000000..c26c4ca --- /dev/null +++ b/core/dr-pipeline/src/ops/noise_reduction.rs @@ -0,0 +1,912 @@ +//! TRACES: FR-DEV-3 +//! Noise reduction — luminance and chroma, as two independent amounts. +//! +//! # Why two controls and not one +//! +//! Sensor noise arrives as two quite different faults, and a photographer +//! treats them differently because they cost different things to remove. +//! +//! **Luminance noise** is fine, high-frequency grain in lightness. It sits at +//! the same spatial frequency as real detail — eyelashes, fabric weave, tree +//! bark — so the eye cannot be given more smoothing without also being given +//! less texture. The radius that helps is one or two photosites, and past +//! about three the picture stops looking like a photograph and starts looking +//! like a painting. Many photographers deliberately leave some. +//! +//! **Chroma noise** is coarse, blotchy and low-frequency: magenta and green +//! patches tens of pixels across, produced by the demosaic interpolating +//! between colour-filtered sites that disagree. Nothing in a photograph looks +//! like it, so it can be smoothed hard — and it has to be, because a radius +//! of two pixels does not touch a blotch of twenty. Human spatial acuity for +//! colour is roughly a quarter of that for lightness, which is why a +//! chroma-only blur that would be obvious in luminance is invisible here, and +//! is the same fact JPEG chroma subsampling has exploited since 1992. +//! +//! So the radius that is *correct* differs between the two by roughly an +//! order of magnitude. That is the reason these are two amounts rather than +//! one: a single slider would either under-treat the colour blotches or +//! destroy the detail, and there is no setting at which it does neither. +//! +//! # The split, and why it makes the two amounts genuinely independent +//! +//! Each pass decomposes the linear sRGB colour into a luminance and a colour +//! difference: +//! +//! ```text +//! y = luminance(c) Rec. 709 weights, exact in this space +//! d = c - vec3(y) luminance(d) == 0, by construction +//! c = vec3(y) + d exactly, up to floating-point rounding +//! ``` +//! +//! `d` carries no lightness at all: the weights sum to one, so subtracting a +//! grey of the same luminance leaves a vector whose own luminance is zero. +//! The luminance pass therefore replaces `y` and returns `d` untouched, and +//! the chroma passes replace `d` and return `y` untouched. Neither can leak +//! into the other, which is what lets a photographer set the two sliders +//! independently and get what they say rather than their product. +//! +//! This is also why the split is taken *here* rather than in the fused pass: +//! the detail stage runs after the camera matrix, where the working space is +//! linear sRGB and a Rec. 709 luminance is a luminance rather than a weighted +//! sum of whatever the colour filter array's dyes happened to pass. +//! +//! # The filter: a bilateral, in two different arrangements +//! +//! A plain Gaussian is not an option. Denoising is exactly the problem of +//! averaging pixels that differ only by noise while refusing to average +//! pixels that differ because the scene does, and a Gaussian cannot tell the +//! difference — it removes grain and edges in the same proportion, which is +//! the smeared look that makes noise reduction recognisable at a glance. +//! +//! A **bilateral filter** multiplies the spatial weight by a *range* weight +//! that falls off with how different the neighbour's value is, so a neighbour +//! across an edge contributes almost nothing and the edge survives the +//! average that removes the grain either side of it. It is the conventional +//! edge-preserving choice; it needs neither a guide image nor the per-window +//! statistics a guided filter accumulates, and it is one expression per tap — +//! which matters, because this runs on the frame path (ARCH §6.1). +//! +//! It is also, in its exact form, quadratic: a radius *r* costs `(2r+1)²` +//! taps. That is affordable at the luminance radius and ruinous at the chroma +//! radius, and the two are arranged differently in consequence: +//! +//! | | radius (source px) | arrangement | taps / pixel | dispatches | +//! |---|---|---|---|---| +//! | luminance | 1.0 … 2.5 | exact 2-D bilateral | 9 … 49 | 1 | +//! | chroma | 2.0 … 12.0 | separable bilateral | 10 … 50 | 2 | +//! +//! The worst case with both at full strength is **99 taps per pixel across +//! three dispatches**, against 49 + 625 = 674 for the exact 2-D form of both. +//! The chroma pass is where all of that saving is. +//! +//! **The luminance pass is exact rather than separable** because at these +//! radii the separable form is not actually cheaper in the way that matters: +//! r = 2 is 25 taps in one dispatch against 20 taps in two, and the second +//! dispatch costs a full-frame `rgba16float` write and read that the taps +//! saved do not pay for. It is also the higher-quality answer, with none of +//! the axis-aligned streaking the approximation can show. +//! +//! **The chroma pass is separable** — a 1-D bilateral along x, then along y, +//! the approximation Pham and van Vliet published in 2005. It is an +//! approximation and not an identity: a bilateral's range weights make the +//! two-dimensional kernel non-separable in principle, and the residual shows +//! as faint axis-aligned structure along strong diagonal edges. That is +//! acceptable here for the same reason the large radius is acceptable — it is +//! in chroma, where the eye's spatial acuity is four times lower — and the +//! alternative, 625 taps a pixel at 4K, is roughly five gigataps a frame and +//! not a frame path at all. Where the approximation *would* be visible, in +//! luminance, it is not used. +//! +//! # The threshold, and what it is a fraction of +//! +//! A bilateral needs to know how large a difference counts as noise. A single +//! absolute number in linear light cannot say: linear light puts middle grey +//! at 0.18, so a threshold tuned for a highlight is roughly a hundred times +//! too coarse for a shadow and would flatten it completely. +//! +//! The threshold is therefore proportional to the square root of the signal: +//! +//! ```text +//! sigma(y) = k * sqrt(max(y, 0) + NOISE_FLOOR) +//! ``` +//! +//! which is the photon-noise law — the arrival of light is Poisson, so its +//! variance equals its mean and its standard deviation goes as the square +//! root. `NOISE_FLOOR` stands in for the sensor's read noise, which does not +//! vanish at black, and keeps `sigma` finite there instead of collapsing to +//! zero and switching the filter off exactly where noise is worst. +//! +//! **The honest limitation.** By the time this stage runs, exposure, the tone +//! curve and the recovery controls have already moved these values, so they +//! are no longer proportional to photon counts and the law is an +//! approximation rather than a measurement. It is kept because it is a far +//! better approximation than a constant — the tone mapping is monotone and +//! only gently compressive, so the ordering and the rough scaling survive it +//! — and because the thing that *would* be exact is FR-DEV-3g's learned +//! denoiser, operating in the raw domain where the noise model still holds. +//! This is the conventional path that degrades to when no model is present, +//! and it is not trying to be it. +//! +//! # Radius units +//! +//! Both radii are stated in **source pixels** and converted through +//! [`RenderScale::source_pixels`] at every render. Noise is a property of the +//! sensor and of the demosaic: its grain is about one photosite across +//! because photosites are what recorded it, and that stays true regardless of +//! how large the frame is drawn on screen or how many megapixels the body +//! has. [`RenderScale::frame_fraction`], the other unit, would say the +//! opposite — that grain covers a fixed proportion of the *picture* — so the +//! same body's files would need different settings as their pixel count +//! changed, and a crop would need different settings from the frame it came +//! out of. +//! +//! The consequence is the one [`crate::detail`] documents: on a heavy proxy a +//! luminance radius of one source pixel is a fraction of a render pixel, the +//! information it would act on was thrown away by the downscale, and this +//! operation emits no luminance pass at all rather than drawing a plausible +//! lie. The chroma radius, ten times larger, still resolves — which is also +//! true of the fault it treats, since a blotch twenty pixels across survives +//! being halved. + +use crate::descriptor::{ + Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit, +}; +use crate::detail::{DetailPass, DetailStage, RenderScale}; +use crate::operation::{Affects, Helper, Operation, Uniform}; + +pub const ID: OpId = OpId("noise_reduction"); +pub const LUMINANCE: ParamId = ParamId("luminance"); +pub const CHROMA: ParamId = ParamId("chroma"); + +static DESCRIPTOR: OpDescriptor = OpDescriptor { + id: ID, + label: LocalizedKey("op.noise_reduction"), + attributes: &[Attribute::Detail], + // Zero to a hundred rather than the symmetric `amount` shape the tonal + // controls use. There is no meaningful negative: "minus fifty noise + // reduction" would be adding grain, which is a look rather than a repair + // and belongs to a different operation carrying `Attribute::Effect`. A + // control whose left half does nothing is worse than one that stops. + params: &[ + ParamDescriptor::scalar( + "luminance", + "param.noise_reduction.luminance", + 0.0, + 100.0, + 0.0, + Unit::None, + Scale::Linear, + 0, + ), + ParamDescriptor::scalar( + "chroma", + "param.noise_reduction.chroma", + 0.0, + 100.0, + 0.0, + Unit::None, + Scale::Linear, + 0, + ), + ], +}; + +/// The luminance radius at the lowest and the highest amount, in **source** +/// pixels. +/// +/// It starts at one rather than at zero because a kernel smaller than a pixel +/// is not a kernel; the amount fades the *threshold* in from zero instead, so +/// the control is still continuous at its neutral. It stops at 2.5 because +/// past roughly three source pixels a luminance average stops removing grain +/// and starts removing the subject — the point at which every editor's +/// luminance slider gets described as watercolour. +const LUMA_RADIUS: (f32, f32) = (1.0, 2.5); + +/// The chroma radius at the lowest and the highest amount, in **source** +/// pixels. +/// +/// An order of magnitude larger, because the fault is an order of magnitude +/// larger: demosaic-born colour blotches are tens of pixels across and a +/// two-pixel average does not see them. +const CHROMA_RADIUS: (f32, f32) = (2.0, 12.0); + +/// Hard ceilings on the kernel actually dispatched, in **render** pixels. +/// +/// Necessary because [`RenderScale::ratio`] exceeds one when the view is +/// zoomed past 1:1 — the render target keeps its size while the region it +/// covers shrinks — so a radius in source pixels can ask for an arbitrarily +/// large kernel at high magnification. Without a cap, zooming to 800% would +/// quietly turn a twelve-pixel chroma radius into a ninety-six-pixel one and +/// cost sixty-four times the taps, at exactly the moment the user is +/// inspecting the result closely and most wants the view to stay responsive. +/// Clamping instead means the effect stops growing past the point where more +/// of it would be visible anyway. +const LUMA_KERNEL_CAP: u32 = 3; +const CHROMA_KERNEL_CAP: u32 = 16; + +/// The luminance range threshold at full amount, as a coefficient on +/// `sqrt(signal)`. +/// +/// At middle grey this is `0.075 * sqrt(0.18) ≈ 0.032`, about three percent +/// of full scale — roughly eight 8-bit code values, which is the grain of a +/// high-ISO frame. Much larger and it would start treating real texture as +/// noise. +const LUMA_SIGMA: f32 = 0.075; + +/// The chroma range threshold at full amount, on the length of the colour +/// difference vector. +/// +/// Far larger than the luminance threshold because it is allowed to be: a +/// genuine colour boundary separates colours by much more than this — a +/// saturated red sits about 0.84 from grey — while chroma noise is a few +/// hundredths. That gap is the whole reason chroma can be filtered hard +/// without visible bleeding. +const CHROMA_SIGMA: f32 = 0.20; + +/// How tightly the chroma passes are steered by luminance, at the lowest and +/// the highest amount. +/// +/// The chroma filter weights a neighbour by *both* how far its colour is and +/// how far its lightness is. The lightness term is a cross-bilateral guide in +/// the ordinary sense — the cleaner channel steering the noisier one — and it +/// is what stops colour crossing a boundary the colour channel itself cannot +/// see: a dark object against a light background of the same hue. +/// +/// It does not start at zero. A guide with a zero threshold rejects every +/// neighbour, and the filter would do nothing however far the colour +/// threshold was opened. It widens with the amount because a photographer +/// asking for more chroma denoising has a noisier frame, whose luminance — +/// the guide itself — is also noisier and would otherwise break the weights. +const CHROMA_GUIDE_SIGMA: (f32, f32) = (0.06, 0.16); + +/// The read-noise floor, in linear working units. +/// +/// Keeps `sigma` finite at black. Roughly 1/400 of full scale, about where a +/// deep shadow sits after a normal rendering: small enough not to affect a +/// midtone, large enough that the filter does not switch itself off in the +/// shadows. +const NOISE_FLOOR: f32 = 0.0025; + +/// TRACES: FR-DEV-3 +/// Luminance and chroma noise reduction, as two independent amounts. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoiseReduction { + luminance: f32, + chroma: f32, +} + +impl NoiseReduction { + pub fn new() -> Self { + Self::default() + } + + /// Both amounts at once, for tests and for a preset applying the pair. + pub fn with_amounts(luminance: f32, chroma: f32) -> Self { + Self { luminance, chroma } + } + + /// The luminance radius in **source** pixels, or `None` at neutral. + pub fn luminance_radius(&self) -> Option { + (self.luminance > 0.0).then(|| lerp(LUMA_RADIUS, self.luminance / 100.0)) + } + + /// The chroma radius in **source** pixels, or `None` at neutral. + pub fn chroma_radius(&self) -> Option { + (self.chroma > 0.0).then(|| lerp(CHROMA_RADIUS, self.chroma / 100.0)) + } + + /// The luminance kernel this render would dispatch, in render pixels. + /// + /// Zero means "not at this resolution": either the control is neutral, or + /// the radius is smaller than a render pixel and the detail it would act + /// on is not present in this render at all (see [`RenderScale::resolves`]). + /// + /// Exposed so a test — and, in time, an interface offering to zoom to 1:1 + /// — can state the expected kernel without repeating the rounding rule, + /// which is how a test comes to agree with a bug. + pub fn luminance_kernel(&self, scale: RenderScale) -> u32 { + kernel(self.luminance_radius(), scale, LUMA_KERNEL_CAP) + } + + /// The chroma kernel this render would dispatch, in render pixels. + pub fn chroma_kernel(&self, scale: RenderScale) -> u32 { + kernel(self.chroma_radius(), scale, CHROMA_KERNEL_CAP) + } + + /// The luminance range threshold coefficient — the `k` in + /// `sigma = k * sqrt(signal + floor)`. + fn luma_sigma(&self) -> f32 { + LUMA_SIGMA * (self.luminance / 100.0) + } + + /// The chroma passes' two thresholds: the luminance guide, then the + /// colour difference. + fn chroma_sigmas(&self) -> (f32, f32) { + let t = self.chroma / 100.0; + (lerp(CHROMA_GUIDE_SIGMA, t), CHROMA_SIGMA * t) + } +} + +fn lerp((low, high): (f32, f32), t: f32) -> f32 { + low + (high - low) * t.clamp(0.0, 1.0) +} + +/// A radius in source pixels, as the kernel to walk in render pixels. +/// +/// Zero when [`RenderScale::resolves`] says the radius does not survive this +/// render. That check rather than the rounding, because the two disagree +/// exactly where it matters: 0.6 of a render pixel *rounds* to one, and a +/// one-pixel kernel would then be dispatched to remove grain that the +/// downscale averaged away before this stage ran. It would cost a dispatch to +/// draw something that is not in the picture, and — worse — it would look +/// like an effect, so a photographer would tune against it. +/// +/// Otherwise rounded rather than truncated, so a 1.4-pixel radius is one pixel +/// and a 1.6-pixel radius is two; and capped, so that zooming past 1:1 cannot +/// make the cost of a frame grow without bound. +fn kernel(radius: Option, scale: RenderScale, cap: u32) -> u32 { + let Some(radius) = radius else { return 0 }; + if !scale.resolves(radius) { + return 0; + } + (scale.source_pixels(radius).round().max(1.0) as u32).min(cap) +} + +/// The Gaussian spatial falloff for a kernel of this radius, as the +/// `1 / (2 * sigma^2)` the shader multiplies a squared distance by. +/// +/// `sigma` is half the radius, which puts the weight at the rim of the kernel +/// at `exp(-2)`, about 0.135 — small enough that the kernel has no visible +/// hard edge, large enough that the outermost taps are still doing work +/// rather than being paid for and discarded. +fn inv_spatial(kernel: u32) -> f32 { + let sigma = (kernel as f32 * 0.5).max(0.5); + 1.0 / (2.0 * sigma * sigma) +} + +impl Operation for NoiseReduction { + fn descriptor(&self) -> &'static OpDescriptor { + &DESCRIPTOR + } + + fn set_param(&mut self, id: ParamId, value: f32) { + match id { + LUMINANCE => self.luminance = value, + CHROMA => self.chroma = value, + _ => log::warn!("noise_reduction: unknown parameter {id}"), + } + } + + fn param(&self, id: ParamId) -> f32 { + match id { + LUMINANCE => self.luminance, + CHROMA => self.chroma, + _ => 0.0, + } + } + + fn is_active(&self) -> bool { + self.luminance > 0.0 || self.chroma > 0.0 + } + + /// Never called: a detail operation contributes no fused fragment, and + /// `compose_full` filters it out before asking. + fn wgsl_body(&self) -> String { + String::new() + } + + fn uniforms(&self) -> Vec { + Vec::new() + } + + fn affects(&self) -> Affects { + Affects::Detail + } + + fn detail(&self) -> Option<&dyn DetailStage> { + Some(self) + } + + /// The shared `luminance` helper, which both kernels call. + /// + /// Taken from `ops/_helpers.yaml` rather than defined here, so that + /// lightness means one thing across the whole pipeline. Its own + /// documentation calls the Rec. 709 weights an approximation, which they + /// are in camera space — but the detail stage runs after the camera + /// matrix, in linear sRGB, where they are exactly the right weights. + fn helpers(&self) -> &'static [Helper] { + HELPERS + } +} + +static HELPERS: &[Helper] = &[crate::ops::helpers::LUMINANCE]; + +impl DetailStage for NoiseReduction { + fn passes(&self, scale: RenderScale) -> Vec { + let mut passes = Vec::new(); + + // Luminance first, and the order is not arbitrary: the chroma passes + // are steered by luminance, and a luminance that has already been + // denoised is a cleaner guide than a noisy one. Doing it the other way + // round would make the chroma weights noisier for no gain anywhere. + // When the luminance control is neutral the guide is simply the + // luminance as it arrived, which is the honest fallback. + let luma = self.luminance_kernel(scale); + if luma > 0 { + passes.push(DetailPass { + label: "luminance", + radius: luma, + uniforms: vec![ + Uniform { + name: "radius", + value: luma as f32, + }, + Uniform { + name: "inv_spatial", + value: inv_spatial(luma), + }, + Uniform { + name: "sigma_k", + value: self.luma_sigma(), + }, + Uniform { + name: "noise_floor", + value: NOISE_FLOOR, + }, + ], + wgsl: LUMA_WGSL.to_string(), + }); + } + + let chroma = self.chroma_kernel(scale); + if chroma > 0 { + let (guide, colour) = self.chroma_sigmas(); + // One body, dispatched twice with the step vector rotated. Writing + // it as two passes over one kernel rather than as two kernels is + // what keeps the two halves of a separable filter from drifting + // apart — the classic way an axis ends up filtered differently + // from the other and the result acquires a diagonal bias. + for (index, (sx, sy)) in [(1.0, 0.0), (0.0, 1.0)].into_iter().enumerate() { + passes.push(DetailPass { + label: if index == 0 { + "chroma-horizontal" + } else { + "chroma-vertical" + }, + radius: chroma, + uniforms: vec![ + Uniform { + name: "radius", + value: chroma as f32, + }, + Uniform { + name: "step_x", + value: sx, + }, + Uniform { + name: "step_y", + value: sy, + }, + Uniform { + name: "inv_spatial", + value: inv_spatial(chroma), + }, + Uniform { + name: "guide_k", + value: guide, + }, + Uniform { + name: "chroma_k", + value: colour, + }, + Uniform { + name: "noise_floor", + value: NOISE_FLOOR, + }, + ], + wgsl: CHROMA_WGSL.to_string(), + }); + } + } + + passes + } +} + +/// The exact two-dimensional bilateral, acting on luminance alone. +const LUMA_WGSL: &str = "\ +// A bilateral filter over luminance: the spatial Gaussian every blur has, +// multiplied by a range term that falls off with how different the +// neighbour's lightness is. That second factor is the entire difference +// between denoising and smearing — a neighbour on the far side of an edge +// contributes essentially nothing, so the edge survives the average that +// removes the grain either side of it. +// +// Exact rather than separable. At the one-to-three-pixel radii a luminance +// kernel is allowed, the two-pass approximation saves a handful of taps and +// costs a whole extra full-frame write and read, and it can leave axis-aligned +// streaking in the one channel the eye reads sharpest. +let r = i32(radius); +let y0 = luminance(c); + +// Photon noise: the standard deviation of a signal goes as its square root, so +// the threshold has to as well. A constant would be a hundred times too coarse +// in the shadows relative to the highlights and would flatten them. +// `noise_floor` stands for read noise and keeps this finite at black. +let sigma = max(sigma_k * sqrt(max(y0, 0.0) + noise_floor), 1e-5); +let inv_range = 1.0 / (2.0 * sigma * sigma); + +var weight_sum = 0.0; +var luma_sum = 0.0; +for (var dy = -r; dy <= r; dy = dy + 1) { + for (var dx = -r; dx <= r; dx = dx + 1) { + let n = luminance(tap(coord, vec2(dx, dy))); + let dl = n - y0; + let distance2 = f32(dx * dx + dy * dy); + let w = exp(-(distance2 * inv_spatial + dl * dl * inv_range)); + weight_sum = weight_sum + w; + luma_sum = luma_sum + n * w; + } +} + +// Substitute the filtered luminance and leave the colour difference exactly as +// it arrived. `c - vec3(y0)` has zero luminance by construction, so adding the +// change in lightness back changes lightness and nothing else — which is what +// keeps this control independent of the chroma one. +c = c + vec3(luma_sum / weight_sum - y0);"; + +/// One axis of the separable cross-bilateral, acting on chroma alone. +const CHROMA_WGSL: &str = "\ +// One axis of a separable bilateral over the colour difference. +// +// Separable because the radius is an order of magnitude larger than the +// luminance one and the exact form is quadratic: at twelve source pixels that +// is 625 taps a pixel, which is not a frame path. Two one-dimensional passes +// are fifty, and the axis-aligned residual the approximation leaves is in +// chroma, where the eye resolves about a quarter of what it resolves in +// lightness. +// +// The weight has two range terms, not one. The colour term is what the filter +// is for. The luminance term is a *guide*: it stops colour crossing a boundary +// the colour channel itself cannot see — a dark object against a light +// background of the same hue — by letting the cleaner channel steer the +// noisier one. +let r = i32(radius); +let step = vec2(i32(step_x), i32(step_y)); + +let y0 = luminance(c); +let d0 = c - vec3(y0); + +// Both thresholds follow the same square-root-of-signal law, for the reason +// the luminance pass states. +let level = sqrt(max(y0, 0.0) + noise_floor); +let guide = max(guide_k * level, 1e-5); +let colour = max(chroma_k * level, 1e-5); +let inv_guide = 1.0 / (2.0 * guide * guide); +let inv_colour = 1.0 / (2.0 * colour * colour); + +var weight_sum = 0.0; +var chroma_sum = vec3(0.0); +for (var i = -r; i <= r; i = i + 1) { + let n = tap(coord, step * i); + let yn = luminance(n); + let dn = n - vec3(yn); + let dl = yn - y0; + let dc = dn - d0; + let w = exp(-(f32(i * i) * inv_spatial + dl * dl * inv_guide + dot(dc, dc) * inv_colour)); + weight_sum = weight_sum + w; + chroma_sum = chroma_sum + dn * w; +} + +// This pixel's own luminance, unchanged, plus the filtered colour difference. +// Every `dn` has zero luminance, so their weighted mean does too and the +// reconstructed colour keeps exactly the lightness it arrived with. +c = vec3(y0) + chroma_sum / weight_sum;"; + +#[cfg(test)] +mod tests { + use super::*; + use dr_types::ColourSpace; + + /// A 24 MP frame, and the panel a develop view might show it in. + const FULL: (u32, u32) = (6000, 4000); + + fn nr(luminance: f32, chroma: f32) -> NoiseReduction { + NoiseReduction::with_amounts(luminance, chroma) + } + + fn chain_with(op: NoiseReduction) -> Vec> { + vec![Box::new(op)] + } + + fn compose(op: NoiseReduction, scale: RenderScale) -> crate::detail::ComposedDetail { + crate::detail::compose_detail(&chain_with(op), scale, ColourSpace::Srgb) + } + + #[test] + fn neutral_costs_the_edit_nothing() { + // The rule the whole pipeline rests on. An unedited photograph must + // not pay for a denoiser it is not using — no pass, no dispatch, and + // the fused shader ends exactly as it always did. + let op = nr(0.0, 0.0); + assert!(!op.is_active()); + assert!(compose(op, RenderScale::full(FULL)).is_empty()); + } + + #[test] + fn each_amount_reaches_the_shader_on_its_own() { + // The point of two controls: either alone must produce its own passes + // and nothing of the other's. A denoiser that emitted the chroma + // dispatches whenever luminance was on would cost two thirds of the + // stage for an effect the user did not ask for — and would be + // invisible in the picture, because at a zero threshold the chroma + // filter is very nearly the identity. + let scale = RenderScale::full(FULL); + + let labels = |op| { + compose(op, scale) + .passes + .iter() + .map(|p| p.label.clone()) + .collect::>() + }; + + assert_eq!(labels(nr(50.0, 0.0)), ["noise_reduction/luminance"]); + assert_eq!( + labels(nr(0.0, 50.0)), + [ + "noise_reduction/chroma-horizontal", + "noise_reduction/chroma-vertical" + ] + ); + + let both = compose(nr(50.0, 50.0), scale); + assert_eq!(both.len(), 3); + // The luminance pass runs first, so the chroma guide is the denoised + // luminance rather than the raw one. + assert_eq!(both.passes[0].label, "noise_reduction/luminance"); + // And only the last pass in the whole chain performs the output + // transform, whichever pass that happens to be. + assert!(!both.passes[0].writes_output); + assert!(!both.passes[1].writes_output); + assert!(both.passes[2].writes_output); + } + + #[test] + fn chroma_always_reaches_further_than_luminance() { + // The reason these are two controls rather than one. Colour blotches + // are tens of pixels across and grain is one or two, so no single + // radius treats both — and if this ever inverted, the chroma slider + // would have become an expensive second luminance slider. + for amount in [1.0, 25.0, 50.0, 75.0, 100.0] { + let op = nr(amount, amount); + let l = op.luminance_radius().expect("active"); + let c = op.chroma_radius().expect("active"); + assert!(c > l * 2.0, "at {amount}: chroma {c} vs luminance {l}"); + } + } + + #[test] + fn a_radius_is_a_count_of_sensor_pixels_not_a_fraction_of_the_frame() { + // TRACES: FR-DSP-1 — the decision this operation is most likely to + // get wrong, stated as the property that distinguishes the two units. + // + // Noise is made by photosites, so its grain is the same size in + // *source* pixels however the frame is being rendered. Convert with + // `source_pixels` and the kernel in render pixels tracks the scale; + // convert with `frame_fraction` and it would instead be constant for a + // constant render size, which is a different — and wrong — claim. + let op = nr(100.0, 100.0); + let radius = op.chroma_radius().expect("active"); + assert!((radius - 12.0).abs() < 1e-6); + + // The same photograph at three sizes. Measured back in source pixels, + // the kernel is the same length every time. + for render in [(1500u32, 1000u32), (3000, 2000), (6000, 4000)] { + let scale = RenderScale::new(render, FULL); + let in_source_pixels = op.chroma_kernel(scale) as f32 / scale.ratio(); + assert!( + (in_source_pixels - radius).abs() < 0.5, + "{render:?} denoised {in_source_pixels} source pixels, not {radius}" + ); + } + + // And the distinguishing case: one panel, two cameras. A 24 MP file + // and a 96 MP file shown at the same size have the same *frame + // fraction* per render pixel but four times the photosites, so the + // sensor-pixel radius covers a quarter as much of the picture in the + // second. A frame-fraction radius would have given both the same + // kernel, which would mean the 96 MP body needed a different setting + // to remove the same grain. + let render = (1500, 1000); + let small = op.chroma_kernel(RenderScale::new(render, (6000, 4000))); + let large = op.chroma_kernel(RenderScale::new(render, (12000, 8000))); + assert!( + small > large, + "denser sensor, same panel: {small} vs {large} render pixels" + ); + } + + #[test] + fn a_luminance_radius_too_small_to_draw_is_not_drawn() { + // The honest limit `RenderScale` exists to report. At a quarter-size + // proxy a 2.5-source-pixel luminance radius is 0.6 render pixels: the + // grain it would remove was averaged away by the downscale before this + // stage ran, and there is no kernel that represents a fraction of a + // pixel. Emitting a pass anyway would burn a dispatch to draw a guess. + let proxy = RenderScale::new((1500, 1000), FULL); + let op = nr(100.0, 100.0); + assert_eq!(op.luminance_kernel(proxy), 0); + assert!(!proxy.resolves(op.luminance_radius().expect("active"))); + + // Chroma is a different case at the same scale, and the difference is + // real rather than a rounding accident: a blotch twenty pixels across + // is still ten pixels across in a half-size proxy, so it both survives + // the downscale and can still be removed. + assert_eq!(op.chroma_kernel(proxy), 3); + let composed = compose(op, proxy); + assert_eq!(composed.len(), 2, "chroma alone survives a heavy proxy"); + } + + #[test] + fn zooming_past_one_to_one_does_not_let_the_kernel_run_away() { + // A 1:1 view already renders one render pixel per source pixel; at + // 800% there are eight. Without the cap the chroma kernel would be + // ninety-six render pixels — sixty-four times the taps — precisely + // when the user is looking closely and least tolerant of a stall. + let magnified = RenderScale::new((2000, 2000), (250, 250)); + assert!((magnified.ratio() - 8.0).abs() < 1e-6); + let op = nr(100.0, 100.0); + assert_eq!(op.chroma_kernel(magnified), CHROMA_KERNEL_CAP); + assert_eq!(op.luminance_kernel(magnified), LUMA_KERNEL_CAP); + } + + #[test] + fn the_declared_halo_is_the_kernel_the_shader_walks() { + // ARCH §5.3 grows a tile by the declared radius before scheduling it. + // An understated radius shows as a seam at every tile boundary, which + // looks like a driver bug rather than like an arithmetic error — so + // the number handed to the scheduler and the number the loop counts to + // must be the same number, not two that happen to agree today. + let scale = RenderScale::full(FULL); + let op = nr(100.0, 100.0); + for pass in compose(op, scale).passes { + let declared = pass.radius as f32; + let walked = pass.uniforms[crate::detail::DETAIL_BASE_UNIFORM_FIELDS]; + assert_eq!(declared, walked, "{}", pass.label); + } + assert_eq!(compose(op, scale).radius(), op.chroma_kernel(scale)); + } + + #[test] + fn every_pass_declares_a_uniform_block_the_gpu_will_accept() { + // A uniform struct whose size is not a multiple of sixteen is rejected + // outright by the WGSL uniform address space rules, and the failure + // arrives as a compile error against generated source a long way from + // here. + for pass in compose(nr(60.0, 60.0), RenderScale::full(FULL)).passes { + assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label); + assert!( + pass.uniforms.iter().all(|v| v.is_finite()), + "{} uploaded a non-finite uniform", + pass.label + ); + } + } + + #[test] + fn the_two_chroma_passes_are_one_kernel_along_two_axes() { + // A separable filter is only separable if both halves are the same + // filter. The bodies must be identical and the step vectors must be + // perpendicular unit steps; anything else is two different blurs whose + // composition is not the two-dimensional one intended. + let passes = nr(0.0, 80.0).passes(RenderScale::full(FULL)); + assert_eq!(passes.len(), 2); + assert_eq!(passes[0].wgsl, passes[1].wgsl); + + let step = |p: &DetailPass| { + let get = |name| { + p.uniforms + .iter() + .find(|u| u.name == name) + .expect("declared") + .value + }; + (get("step_x"), get("step_y")) + }; + assert_eq!(step(&passes[0]), (1.0, 0.0)); + assert_eq!(step(&passes[1]), (0.0, 1.0)); + + // Everything else about the two must match, or one axis is filtered + // harder than the other and a round blotch comes out oval. + assert_eq!(passes[0].radius, passes[1].radius); + for name in ["radius", "inv_spatial", "guide_k", "chroma_k", "noise_floor"] { + let of = |p: &DetailPass| { + p.uniforms + .iter() + .find(|u| u.name == name) + .expect("declared") + .value + }; + assert_eq!(of(&passes[0]), of(&passes[1]), "{name}"); + } + } + + #[test] + fn the_threshold_opens_with_the_amount_and_closes_at_neutral() { + // The amount is a *threshold* as much as a radius: it decides how + // large a difference the filter is willing to call noise. If it did + // not reach zero at the neutral end the control would be + // discontinuous, and the first pixel of travel on the slider would + // visibly flatten the image. + let mut previous = 0.0; + for amount in [1.0, 10.0, 50.0, 100.0] { + let sigma = nr(amount, 0.0).luma_sigma(); + assert!(sigma > previous, "at {amount}: {sigma} <= {previous}"); + previous = sigma; + } + assert_eq!(nr(0.0, 0.0).luma_sigma(), 0.0); + + // The chroma guide is the exception, and deliberately so: a guide with + // a zero threshold rejects every neighbour, so the filter would do + // nothing at all at low amounts however wide the colour threshold was. + let (guide, colour) = nr(0.0, 1.0).chroma_sigmas(); + assert!(guide > 0.0, "a zero guide would reject every neighbour"); + assert!(colour > 0.0); + } + + #[test] + fn a_chroma_threshold_is_far_wider_than_a_luminance_one() { + // Not a tuning detail but the reason the two are separable problems. + // Chroma noise is a few hundredths from grey and a real colour + // boundary is most of the way to a primary, so the gap between them is + // wide enough to filter hard through. Luminance has no such gap, which + // is why its threshold has to stay tight. + let (_, colour) = nr(100.0, 100.0).chroma_sigmas(); + assert!(colour > nr(100.0, 100.0).luma_sigma() * 2.0); + } + + #[test] + fn parameters_round_trip_and_an_unknown_one_is_ignored() { + // What the sidecar, the history and the preset system all rely on. + let mut op = NoiseReduction::new(); + op.set_param(LUMINANCE, 40.0); + op.set_param(CHROMA, 70.0); + assert_eq!(op.param(LUMINANCE), 40.0); + assert_eq!(op.param(CHROMA), 70.0); + op.set_param(ParamId("sharpness"), 99.0); + assert_eq!(op.param(LUMINANCE), 40.0); + assert_eq!(op.param(ParamId("sharpness")), 0.0); + } + + #[test] + fn the_generated_wgsl_addresses_its_own_uniforms() { + // The composer prefixes each uniform with the operation id and the + // pass index, so two operations may both call a uniform `radius` and + // neither has to know. A body that slipped through unrewritten would + // fail to compile against the generated struct. + let composed = compose(nr(50.0, 50.0), RenderScale::full(FULL)); + assert!(composed.passes[0] + .source + .contains("noise_reduction_0_sigma_k: f32,")); + assert!(composed.passes[1] + .source + .contains("noise_reduction_1_chroma_k: f32,")); + assert!(composed.passes[2] + .source + .contains("noise_reduction_2_chroma_k: f32,")); + // The shared luminance helper reaches every pass that calls it. + for pass in &composed.passes { + assert!( + pass.source.contains("fn luminance(c: vec3)"), + "{} calls luminance without defining it", + pass.label + ); + } + // Three passes of one operation are three shaders, and must not share + // a pipeline-cache entry. + let hashes: std::collections::BTreeSet = + composed.passes.iter().map(|p| p.structure_hash).collect(); + assert_eq!(hashes.len(), 3); + } +} diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 6863a65..5b1a6b7 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -891,11 +891,30 @@ impl DevelopSession { /// The mask array is rasterised in source space at proxy size and sampled /// through the framing map, so one array is correct at every output size: /// a 256px thumbnail and a 24 MP export bind the same texture. + /// + /// **And the detail stage with it.** The neighbourhood operations — noise + /// reduction, and the rest of FR-DEV-3's kernels — cannot be fused into + /// the single dispatch, so an edit using one composes a fused pass that + /// hands on *linear* values and a chain of passes that finishes the job + /// (see `dr_pipeline::detail`). Those two halves must be composed from one + /// graph and dispatched together, or the fused shader's storage format + /// does not match the texture bound to it; going through + /// `render_detailed` here is what makes that true of every path at once. + /// It falls through to the plain render when the chain is empty, which is + /// almost every edit, so this costs nothing to the frames that do not + /// need it. + /// + /// `space` has to be the space `shader` was composed for. It is the last + /// pass of the detail chain that performs the output transform when there + /// is one, so the two would otherwise be free to disagree about which + /// primaries the file is in — and the result would be a correctly + /// labelled file with the wrong colours in it (FR-EXP-2). fn render_with_masks( &mut self, shader: &dr_pipeline::operation::ComposedShader, w: u32, h: u32, + space: dr_types::ColourSpace, ) -> Result<(), String> { let ctx = self.ctx.clone(); self.ensure_subject_fields(&ctx); @@ -905,8 +924,30 @@ impl DevelopSession { .then(|| self.masks.as_ref().and_then(|p| p.array())) .flatten(); + // The scale a kernel's radius is converted through. Worked out from + // the framing, so a crop and a zoom are already accounted for: what + // matters to a sensor-sized radius is how many source pixels one + // render pixel stands for, here and now (FR-DSP-1). + let scale = self.graph.render_scale(self.demosaiced.size(), (w, h)); + let detail = self.graph.compose_detail_for(scale, space); + // Detail passes read what the colour pass wrote, so the key they are + // cached against is the colour key: moving a sharpening slider re-runs + // this stage and not the fused one (FR-DEV-3d). + let colour_key = self + .graph + .invalidation() + .through(dr_pipeline::Affects::Colour); + self.adjust - .render_masked(&self.demosaiced, shader, w, h, masks) + .render_detailed( + &self.demosaiced, + shader, + w, + h, + masks, + &detail, + colour_key, + ) .map(|_| ()) .map_err(|e| e.to_string()) } @@ -1605,7 +1646,7 @@ impl DevelopSession { // Rasterise the masks first: the shader addresses array slices by // index, so the array has to describe *this* stack before it is bound. - self.render_with_masks(&shader, w, h)?; + self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?; let texture = self.adjust.output().ok_or("nothing was rendered")?; // The import is fallible on format and usage only, and both are fixed @@ -1729,7 +1770,7 @@ impl DevelopSession { let (w, h) = self.graph.output_size(sw, sh); let shader = self.graph.compose_for(space); - self.render_with_masks(&shader, w, h)?; + self.render_with_masks(&shader, w, h, space)?; let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?; dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string()) @@ -1756,7 +1797,7 @@ impl DevelopSession { let (w, h) = fit(fw, fh, edge.max(1), edge.max(1)); let shader = self.graph.compose_for(dr_types::ColourSpace::Srgb); - self.render_with_masks(&shader, w, h)?; + self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?; let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?; Ok((rw, rh, pixels)) From 97d4bd9061e1da8a72a8014a36fd56bc4a3e728c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:01:18 +0200 Subject: [PATCH 08/27] WIP: clarity and texture Checkpoint committed by the coordinator, not by the authoring agent: the session hit its API limit mid-task and left this work uncommitted. Committed so it survives, NOT because it is finished - expect failing tests and half-applied changes. The agent resumes from here. --- core/dr-gpu/tests/local_contrast.rs | 567 ++++++++++++++ core/dr-pipeline/ops/clarity.yaml | 36 + core/dr-pipeline/ops/texture.yaml | 23 + core/dr-pipeline/src/detail.rs | 85 ++- core/dr-pipeline/src/ops/local_contrast.rs | 828 +++++++++++++++++++++ core/dr-pipeline/src/ops/mod.rs | 4 + 6 files changed, 1542 insertions(+), 1 deletion(-) create mode 100644 core/dr-gpu/tests/local_contrast.rs create mode 100644 core/dr-pipeline/ops/clarity.yaml create mode 100644 core/dr-pipeline/ops/texture.yaml create mode 100644 core/dr-pipeline/src/ops/local_contrast.rs diff --git a/core/dr-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs new file mode 100644 index 0000000..f93e328 --- /dev/null +++ b/core/dr-gpu/tests/local_contrast.rs @@ -0,0 +1,567 @@ +//! Clarity and texture, end to end on a real device. +//! +//! `dr-pipeline`'s tests assert what the composer *generates* — the kernel +//! width, the uniforms, which lines of WGSL each node emits. None of that can +//! tell whether the two passes compose into an unsharp mask, whether the +//! original colour really survives the hand-off from the blur pass to the +//! combining one, or whether the halo the soft limit is supposed to bound is +//! actually bounded in pixels. Those are questions only a GPU answers. +//! +//! # Why every measurement is in stops +//! +//! The controls work on log luminance, and their guarantees are stated in +//! stops: an overshoot of at most `gain * threshold`, an effect that is +//! symmetric about neutral, a strength that does not depend on how bright the +//! subject is. Asserting on 8-bit code values would restate all of that in a +//! unit where none of it is true, and would need a fresh magic number for +//! every brightness tested. So the pixels are decoded back to linear and +//! compared as ratios. +//! +//! # The test image +//! +//! A vertical step between two **midtones** rather than between black and +//! white. Clarity is tapered to nothing at both ends of the range on purpose +//! (see `midtone_weight`), so a 0–255 step is the one edge in the world it is +//! designed to leave alone, and a test built on it would measure the taper +//! working and call it the feature not working. + +use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext}; +use dr_pipeline::descriptor::{OpId, ParamId}; +use dr_pipeline::ops::local_contrast::{Clarity, Texture}; +use dr_pipeline::{Affects, EditGraph, OutputMode}; +use dr_types::ColourSpace; + +const CLARITY: OpId = OpId("clarity"); +const TEXTURE: OpId = OpId("texture"); +const AMOUNT: ParamId = ParamId("amount"); + +/// Large enough that texture's kernel — a tenth of clarity's — is still more +/// than one pixel wide. At 1024 its sigma is 1.2 px; at 256 it would round to +/// a delta and the control would honestly do nothing, which is the behaviour +/// `texture_stops_rather_than_lying_when_the_render_is_too_small` covers and +/// not the behaviour under test here. +const SIZE: u32 = 1024; + +/// The two sides of the step, as sRGB code values. +/// +/// Both well inside the range, and roughly two stops apart — a real edge, of +/// the kind that produces the halo this file exists to bound. +const DARK: u8 = 90; +const BRIGHT: u8 = 175; + +fn ctx() -> Option { + // CI runners and headless machines may have no usable adapter. Skip rather + // than fail, exactly as the rest of this crate's device tests do. + match pollster::block_on(GpuContext::new_headless()) { + Ok(c) => Some(c), + Err(e) => { + eprintln!("skipping: no GPU adapter ({e})"); + None + } + } +} + +fn srgb_decode(v: u8) -> f32 { + let e = v as f32 / 255.0; + if e <= 0.040_45 { + e / 12.92 + } else { + ((e + 0.055) / 1.055).powf(2.4) + } +} + +/// A vertical step from `DARK` to `BRIGHT` at the half-way column. +fn step_edge(ctx: &GpuContext, size: u32, tint: [f32; 3]) -> DemosaicedImage { + let data: Vec = (0..size * size) + .flat_map(|i| { + let x = i % size; + let v = if x < size / 2 { DARK } else { BRIGHT } as f32; + [ + (v * tint[0]).round() as u8, + (v * tint[1]).round() as u8, + (v * tint[2]).round() as u8, + 255, + ] + }) + .collect(); + DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") +} + +/// One row of the rendered image, as linear luminance-ish red values. +fn row(pixels: &[u8], size: u32, y: u32) -> Vec { + (0..size) + .map(|x| pixels[((y * size + x) * 4) as usize]) + .collect() +} + +/// One row as full RGB triples. +fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> { + (0..size) + .map(|x| { + let i = ((y * size + x) * 4) as usize; + [pixels[i], pixels[i + 1], pixels[i + 2]] + }) + .collect() +} + +/// Render one graph with its detail stage and read the pixels back. +fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec { + let shader = graph.compose_for(ColourSpace::Srgb); + let scale = graph.render_scale(source.size(), (out, out)); + let detail = graph.compose_detail_for(scale, ColourSpace::Srgb); + let key = graph.invalidation().through(Affects::Colour); + pass.render_detailed(source, &shader, out, out, None, &detail, key) + .expect("render"); + pass.export_pixels().expect("readback").0 +} + +/// A graph with one of the two controls set and everything else neutral. +fn graph_with(op: OpId, amount: f32) -> EditGraph { + let mut g = EditGraph::default_chain(); + g.set_param(op, AMOUNT, amount); + g +} + +/// How far a pixel moved, in stops, against the same pixel unedited. +fn stops(edited: u8, plain: u8) -> f32 { + (srgb_decode(edited).max(1e-6) / srgb_decode(plain).max(1e-6)).log2() +} + +#[test] +fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() { + // The definition of a local contrast control, as pixels: it must do + // something at the edge and *nothing* a long way from it. An operation + // that brightened the whole bright plateau would be an exposure slider + // with extra steps, and it is the failure a sign error in the base + // produces. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let edge = (SIZE / 2) as usize; + let reach = Clarity::with_amount(100.0) + .kernel(EditGraph::default_chain().render_scale((SIZE, SIZE), (SIZE, SIZE))) + as usize; + + // Far outside the kernel's reach the base equals the pixel, the detail + // signal is zero, and the output must be the input to the last code value. + for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] { + assert!( + edited[x].abs_diff(plain[x]) <= 1, + "column {x} moved by {} away from any edge", + edited[x].abs_diff(plain[x]) + ); + } + + // And at the edge it must do the thing it is for: the bright side lifts, + // the dark side drops, which is what "more local contrast" means. + assert!( + edited[edge] > plain[edge] + 4, + "the bright side of the edge did not lift: {} vs {}", + edited[edge], + plain[edge] + ); + assert!( + edited[edge - 1] + 4 < plain[edge - 1], + "the dark side of the edge did not drop: {} vs {}", + edited[edge - 1], + plain[edge - 1] + ); +} + +#[test] +fn the_soft_limit_bounds_the_halo_at_a_hard_edge() { + // The single most common way clarity is got wrong, held to a number. + // + // `t * tanh(d / t)` saturates at `t`, so no pixel may move further than + // `gain * threshold` stops however violent the edge — a bound that holds + // by construction rather than by tuning, and one this test takes from the + // operation itself rather than restating. + // + // The comparison that gives it meaning is the second assertion: an + // unlimited unsharp mask over this edge would move the bright side by + // about half the step, which is more than twice as far. That is the + // difference between a control and a white glow along the skyline. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let worst = (0..SIZE as usize) + .map(|x| stops(edited[x], plain[x]).abs()) + .fold(0.0f32, f32::max); + let bound = Clarity::with_amount(100.0).overshoot_bound(); + + // A code value's worth of slack: the readback is 8-bit, and a pixel + // sitting exactly on the bound quantises either side of it. + assert!( + worst <= bound + 0.02, + "a pixel moved {worst:.3} stops, past the {bound:.3} the soft limit \ + promises" + ); + + // Half the step is what an unlimited mask would have produced at the very + // edge, since the base there is the mean of the two plateaus. + let unlimited = (srgb_decode(BRIGHT) / srgb_decode(DARK)).log2() / 2.0; + assert!( + worst < unlimited * 0.6, + "the limit is not biting: {worst:.3} stops against the {unlimited:.3} \ + an unlimited unsharp mask would give" + ); + // But it is still a real effect, not a control that does nothing. + assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops"); +} + +#[test] +fn a_proxy_and_an_export_agree_about_the_effect() { + // TRACES: FR-DSP-1 — the decision the radius unit rests on, proved in + // pixels rather than in kernel widths. + // + // Clarity's radius is a fraction of the frame because the control is + // compositional: "separate the subject from its background" is a statement + // about how much of the picture the subject occupies. If that is right, + // the *same edit* rendered at two resolutions must produce an effect of + // the same strength covering the same proportion of the frame — which is + // exactly what a photographer tuning on screen and exporting at full size + // is relying on. + // + // Had the radius been stated in source pixels, the proxy here would show + // half the reach and the export would be a different photograph. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + // Peak excursion in stops, and how far the effect reaches, as a fraction + // of the frame. + let measure = |out: u32| -> (f32, f32) { + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, out), + out, + out / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, out), + out, + out / 2, + ); + + let moved: Vec = (0..out as usize) + .map(|x| stops(edited[x], plain[x]).abs()) + .collect(); + let peak = moved.iter().cloned().fold(0.0f32, f32::max); + // The width of the band that moved by more than a tenth of the peak — + // a threshold relative to the effect, so it means the same thing at + // both sizes. + let touched = moved.iter().filter(|m| **m > peak * 0.1).count(); + (peak, touched as f32 / out as f32) + }; + + let (proxy_peak, proxy_reach) = measure(SIZE / 2); + let (export_peak, export_reach) = measure(SIZE); + + assert!( + (proxy_peak - export_peak).abs() < 0.03, + "the same edit is {proxy_peak:.3} stops on the proxy and \ + {export_peak:.3} in the export" + ); + assert!( + (proxy_reach - export_reach).abs() < 0.02, + "the effect covers {proxy_reach:.3} of the proxy and {export_reach:.3} \ + of the export; a radius tuned on screen must land in the file" + ); + // And it is a real effect at both sizes, not two flat images agreeing. + assert!( + proxy_peak > 0.1 && proxy_reach > 0.02, + "{proxy_peak:.3} stops over {proxy_reach:.3} of the proxy" + ); +} + +#[test] +fn texture_acts_at_a_finer_scale_than_clarity() { + // The whole reason there are two nodes. If the two controls ever reach the + // same distance from an edge, the second slider has become a duplicate of + // the first and a photographer setting both is setting one thing twice. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let reach = |op: OpId| -> usize { + let mut pass = AdjustPass::new(&ctx); + let edited = row( + &render(&mut pass, &graph_with(op, 100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + // How many columns moved by more than a code value — the honest + // measure of "how far from the edge does this control reach". + (0..SIZE as usize) + .filter(|&x| edited[x].abs_diff(plain[x]) > 1) + .count() + }; + + let coarse = reach(CLARITY); + let fine = reach(TEXTURE); + assert!(fine > 0, "texture did nothing at all"); + assert!( + coarse > fine * 4, + "clarity reaches {coarse} columns and texture {fine}; these are not \ + separable scales" + ); +} + +#[test] +fn clarity_moves_luminance_without_moving_hue() { + // The third halo decision, in pixels. The gain is applied as a scale on + // the whole triple, so chromaticity is untouched; boosting the channels + // independently would put a *coloured* fringe along every edge, arriving + // from a control the photographer reads as contrast. + let Some(ctx) = ctx() else { return }; + // A strongly tinted step, so a per-channel mask would show plainly. + let source = step_edge(&ctx, SIZE, [1.0, 0.55, 0.25]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row_rgb( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let edited = row_rgb( + &render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + + // Compare in linear light, where a scale is a scale. The two channel + // ratios together fix the chromaticity, so holding both fixes the colour. + let edge = (SIZE / 2) as usize; + for x in [edge, edge + 1, edge + 4, edge - 1, edge - 4] { + let ratio = |p: [u8; 3], i: usize| srgb_decode(p[i]) / srgb_decode(p[0]).max(1e-6); + for channel in [1, 2] { + let before = ratio(plain[x], channel); + let after = ratio(edited[x], channel); + assert!( + (after - before).abs() < 0.02, + "column {x} channel {channel}: chromaticity moved from \ + {before:.4} to {after:.4} — that is a coloured fringe" + ); + } + } + // And the effect was actually applied here, or the assertion above is + // vacuous. + assert!(edited[edge][0].abs_diff(plain[edge][0]) > 3); +} + +#[test] +fn negative_clarity_softens_the_surface_without_dissolving_the_edge() { + // The soft limit earns its keep in both directions. An unlimited mask at + // −100 subtracts the whole detail signal and turns every edge to mud; + // limited, it removes at most the threshold, so modelling softens and real + // edges stand. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + let mut pass = AdjustPass::new(&ctx); + let softened = row( + &render(&mut pass, &graph_with(CLARITY, -100.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let edge = (SIZE / 2) as usize; + // The sign is the other way round from the positive case: the bright side + // of the edge comes down and the dark side comes up. + assert!( + softened[edge] + 3 < plain[edge], + "negative clarity did not soften: {} vs {}", + softened[edge], + plain[edge] + ); + + // But the step itself survives. Measured in stops across the edge, so the + // claim is about contrast and not about code values. + let step_of = |r: &[u8]| (srgb_decode(r[edge]) / srgb_decode(r[edge - 1])).log2(); + let before = step_of(&plain); + let after = step_of(&softened); + assert!( + after > before * 0.55, + "the edge dissolved: {after:.3} stops left of {before:.3}" + ); +} + +#[test] +fn neutral_controls_cost_the_edit_nothing() { + // Both nodes are in the default chain, and both are the widest kernels in + // the pipeline. An unedited photograph must render through the single + // fused dispatch it always did — no detail pass, no intermediate texture, + // and byte-identical pixels. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, 128, [1.0, 1.0, 1.0]); + let graph = EditGraph::default_chain(); + + assert_eq!( + graph.compose_for(ColourSpace::Srgb).output_mode, + OutputMode::Encoded, + "a neutral detail operation must not change how the fused pass ends" + ); + + let mut pass = AdjustPass::new(&ctx); + render(&mut pass, &graph, &source, 128); + assert_eq!(pass.colour_dispatches(), 1); + assert_eq!(pass.detail_dispatches(), 0); + assert_eq!(pass.detail_allocations(), 0, "nothing was allocated"); +} + +#[test] +fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() { + // TRACES: FR-DEV-3d. Clarity is `Affects::Detail`, so the fused colour + // pass's result is still valid while the slider moves — which for a + // hundred-tap kernel is the difference between an interactive control and + // a slideshow. Invisible in the output by construction, so a dispatch + // counter is the only thing that can see it. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, 256, [1.0, 1.0, 1.0]); + let mut pass = AdjustPass::new(&ctx); + + let mut graph = graph_with(CLARITY, 40.0); + render(&mut pass, &graph, &source, 256); + assert_eq!(pass.colour_dispatches(), 1); + assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes"); + let pipelines = pass.cached_detail_pipelines(); + + for amount in [50.0, 60.0, 70.0] { + graph.set_param(CLARITY, AMOUNT, amount); + render(&mut pass, &graph, &source, 256); + } + assert_eq!( + pass.colour_dispatches(), + 1, + "the fused colour pass re-ran for a change it does not depend on" + ); + assert_eq!(pass.detail_dispatches(), 8); + assert_eq!( + pass.cached_detail_pipelines(), + pipelines, + "an amount is a uniform, not a shader" + ); + + // Turning on the other control adds its own pair, and only its own pair. + graph.set_param(TEXTURE, AMOUNT, 40.0); + render(&mut pass, &graph, &source, 256); + assert_eq!(pass.detail_dispatches(), 12); + assert_eq!(pass.colour_dispatches(), 1); +} + +#[test] +fn the_two_controls_stack_without_overwriting_each_other() { + // Four passes through one ping-pong, with the scratch lane changing hands + // half way. If clarity's combining pass left the colour where its blur + // pass had put it — or if texture's blur overwrote the colour rather than + // the lane — the result would be a blurred image rather than a sharpened + // one, which is loud rather than subtle. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]); + + let mut plain_pass = AdjustPass::new(&ctx); + let plain = row( + &render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE), + SIZE, + SIZE / 2, + ); + + let mut graph = graph_with(CLARITY, 80.0); + graph.set_param(TEXTURE, AMOUNT, 80.0); + let mut pass = AdjustPass::new(&ctx); + let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2); + assert_eq!(pass.detail_dispatches(), 4); + + let edge = (SIZE / 2) as usize; + // Both sides of the edge move the way local contrast moves them... + assert!(both[edge] > plain[edge] + 4); + assert!(both[edge - 1] + 4 < plain[edge - 1]); + // ...and the plateaus are untouched, which a stray blur would not leave. + assert!(both[0].abs_diff(plain[0]) <= 1); + assert!(both[SIZE as usize - 1].abs_diff(plain[SIZE as usize - 1]) <= 1); + + // Stacked, they must reach further than either alone — the coarse control + // still working at its own scale rather than being overwritten by the fine + // one running after it. + let mut clarity_only = AdjustPass::new(&ctx); + let coarse = row( + &render(&mut clarity_only, &graph_with(CLARITY, 80.0), &source, SIZE), + SIZE, + SIZE / 2, + ); + assert!( + both[edge] >= coarse[edge], + "adding texture undid clarity: {} against {}", + both[edge], + coarse[edge] + ); +} + +#[test] +fn texture_contributes_nothing_where_its_scale_does_not_exist() { + // Unlike the acutance family this is not an approximation being hidden. A + // two-pixel surface structure is not present in a 128-pixel rendering of + // the frame, so the honest answer is no pass at all — and clarity, a + // hundred times wider, still runs, which is what a thumbnail should show. + let Some(ctx) = ctx() else { return }; + let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]); + + let mut graph = graph_with(TEXTURE, 100.0); + let mut pass = AdjustPass::new(&ctx); + render(&mut pass, &graph, &source, 128); + assert_eq!( + pass.detail_dispatches(), + 0, + "texture claimed a kernel it cannot draw" + ); + + graph.set_param(CLARITY, AMOUNT, 100.0); + render(&mut pass, &graph, &source, 128); + assert_eq!(pass.detail_dispatches(), 2, "clarity survives a thumbnail"); + + // And texture comes back, exactly, as soon as the view is large enough to + // hold it — no separate path, no fade, just the kernel resolving again. + let mut zoomed = AdjustPass::new(&ctx); + render(&mut zoomed, &graph_with(TEXTURE, 100.0), &source, 1024); + assert_eq!(zoomed.detail_dispatches(), 2); +} diff --git a/core/dr-pipeline/ops/clarity.yaml b/core/dr-pipeline/ops/clarity.yaml new file mode 100644 index 0000000..ea16cd2 --- /dev/null +++ b/core/dr-pipeline/ops/clarity.yaml @@ -0,0 +1,36 @@ +# A hand-written node. `rust:` names the type in `crate::ops` that implements +# `Operation`; its descriptor, its parameters and its passes come from that +# type rather than from this file. It appears here anyway because `ops/` is +# where the pipeline's order is written down, and an order kept half in YAML +# and half in Rust would be worse than either alone. +id: clarity +order: 120 + +attributes: [detail] +rust: Clarity + +why_rust: | + A neighbourhood operation. Clarity is defined by what the pixels around a + pixel are doing, and the schema above describes a function of one colour — + `wgsl:` is handed `c` and no coordinate, which is the wall the detail stage + exists on the other side of. It declares `Affects::Detail` and returns two + `DetailPass`es: a Gaussian of log luminance along each axis, the second of + which also applies the mask. + + A kernel is also not four facts. Stretching this schema to express a + truncation rule, a soft limit and a midtone taper would produce a worse + language than Rust, aimed at one caller. + +placement: | + In the detail group, after noise reduction and before sharpening. + + Order inside the group is not arbitrary. Clarity multiplies local contrast, + so it multiplies noise with it — running it before noise reduction would ask + the denoiser to remove grain that clarity had already amplified into + structure. And capture sharpening belongs last, on the picture as it will + finally be, so that the acutance a photographer judges at 1:1 is the acutance + in the file. + + Before texture, which is a decade finer: coarse before fine, so the fine + control's base is computed on the modelling the coarse one has already + settled. diff --git a/core/dr-pipeline/ops/texture.yaml b/core/dr-pipeline/ops/texture.yaml new file mode 100644 index 0000000..6db3402 --- /dev/null +++ b/core/dr-pipeline/ops/texture.yaml @@ -0,0 +1,23 @@ +# A hand-written node — see `clarity.yaml`, whose implementation this shares. +id: texture +order: 130 + +attributes: [detail] +rust: Texture + +why_rust: | + The same neighbourhood operation as clarity, at a tenth of the scale: one + implementation in `src/ops/local_contrast.rs`, parameterised by the band it + acts on. Two nodes rather than one node with two sliders because the radius + is the *definition* of each control rather than a setting of it, and because + a texture at zero must then cost nothing at all — which `is_active()` gives + for free and a merged node would have had to hand-write. + +placement: | + Immediately after clarity, and for the same reasons: after noise reduction, + which must not be handed amplified grain, and before capture sharpening, + which belongs last. + + After clarity specifically, so that the fine base is computed on the + modelling the coarse control has already settled rather than the other way + round. diff --git a/core/dr-pipeline/src/detail.rs b/core/dr-pipeline/src/detail.rs index cccc909..6943d1b 100644 --- a/core/dr-pipeline/src/detail.rs +++ b/core/dr-pipeline/src/detail.rs @@ -315,6 +315,37 @@ pub struct DetailPass { /// them in the shader. Prefer computing lengths on the CPU in /// [`DetailStage::passes`], where the units are named methods rather /// than an untyped float. + /// - `aux: f32` and `tap_aux(coord, offset) -> f32` — **one scalar per + /// pixel that survives to the next pass**, pre-loaded with what the + /// previous pass left there and written back out unless the body + /// assigns it. + /// + /// # Why `aux` exists + /// + /// The ping-pong hands each pass exactly one texture: what the pass before + /// it wrote. That is enough for a chain of filters — a separable blur is + /// two of them — and it is *not* enough for an unsharp mask, which is the + /// shape of sharpening, clarity, texture and dehaze alike. An unsharp mask + /// needs the blur **and** the original in the same place at the same time, + /// and once the first pass has written its blur the original is gone. + /// + /// Three channels cannot carry both. Even restricted to the case where the + /// operation only moves luminance — so the colour is a luminance and two + /// chromaticity degrees of freedom — the combining pass needs four + /// numbers: the original luminance, two of chromaticity, and the blurred + /// luminance. Four does not fit in three, and no encoding makes it fit. + /// + /// The intermediate is `rgba16float` and its alpha was being written as a + /// constant `1.0` and read by nobody, so the fourth number goes there. A + /// blur pass leaves `c` alone and puts its result in `aux`; the pass after + /// it therefore receives the untouched original *and* the blur, and can + /// subtract one from the other. An operation with no use for the lane says + /// nothing and hands on what it was given. + /// + /// The last pass in the chain writes the display texture, whose alpha is + /// opacity rather than scratch space, so `aux` is readable there and not + /// written. That is exactly the right way round: the combining pass is the + /// one that reads it. /// /// Uniforms are addressed by the bare names declared in [`Self::uniforms`], /// exactly as a fused fragment addresses its own; the composer rewrites @@ -536,7 +567,11 @@ fn compose_one( "rgba16float", " // Another linear intermediate: no clip and no encode, because\n\ \x20 // the pass after this one still has to read real values.\n\ - \x20 textureStore(output, coord, vec4(c, 1.0));" + \x20 //\n\ + \x20 // `aux` rides in alpha. A pass that never touches it hands on\n\ + \x20 // whatever it was given, so the lane costs an operation that\n\ + \x20 // does not want it exactly one copy of a value it already read.\n\ + \x20 textureStore(output, coord, vec4(c, aux));" .to_string(), ) }; @@ -582,6 +617,12 @@ fn tap(coord: vec2, offset: vec2) -> vec3 {{ return textureLoad(source, clamp(coord + offset, vec2(0), last), 0).rgb; }} +// The same neighbour's scratch lane — see `aux` in the body below. +fn tap_aux(coord: vec2, offset: vec2) -> f32 {{ + let last = vec2(textureDimensions(source)) - vec2(1); + return textureLoad(source, clamp(coord + offset, vec2(0), last), 0).a; +}} + {helper_src}{encode_fn} @compute @workgroup_size(8, 8, 1) fn main(@builtin(global_invocation_id) gid: vec3) {{ @@ -596,6 +637,10 @@ fn main(@builtin(global_invocation_id) gid: vec3) {{ let render_scale = u.detail_base.z; var c = tap(coord, vec2(0)); + // One scalar per pixel that survives the hand-off from one pass to the + // next, alongside the colour. See `DetailPass::wgsl` for what it is for + // and why three channels were not enough. + var aux = tap_aux(coord, vec2(0)); {{ {indented} @@ -880,6 +925,44 @@ mod tests { } } + #[test] + fn a_pass_can_hand_a_scalar_to_the_next_one_alongside_the_colour() { + // What makes an unsharp mask — sharpening, clarity, texture, dehaze — + // expressible at all in a chain that hands each pass exactly one + // texture. The blur goes in `aux` and the colour rides through + // untouched, so the pass that combines them receives both; without the + // lane, four numbers would have to fit in three channels and the + // operation could only ever be a blur. + // + // A pass that says nothing about `aux` hands on what it was given, + // which is why the box blur below needs no knowledge of it. + let ops = with_blur(0.05); + let composed = compose_detail( + &ops, + RenderScale::full((512, 512)), + dr_types::ColourSpace::Srgb, + ); + + for pass in &composed.passes { + assert!( + pass.source.contains("fn tap_aux(") && pass.source.contains("var aux = tap_aux("), + "{} cannot read the scratch lane", + pass.label + ); + } + assert!( + composed.passes[0] + .source + .contains("textureStore(output, coord, vec4(c, aux));"), + "an intermediate must carry the lane to the pass after it" + ); + // The last pass writes the display texture, whose alpha is opacity and + // not scratch space. Readable there, not written — which is the right + // way round, because the combining pass is the one that reads it. + assert!(composed.passes[1].writes_output); + assert!(!composed.passes[1].source.contains("vec4(c, aux)")); + } + #[test] fn an_edit_with_no_detail_operation_composes_no_passes() { // The property that keeps the cost of this stage at zero for the diff --git a/core/dr-pipeline/src/ops/local_contrast.rs b/core/dr-pipeline/src/ops/local_contrast.rs new file mode 100644 index 0000000..5724027 --- /dev/null +++ b/core/dr-pipeline/src/ops/local_contrast.rs @@ -0,0 +1,828 @@ +//! TRACES: FR-DEV-3 | FR-DSP-1 +//! Clarity and texture — local contrast at two scales. +//! +//! Both are unsharp masks. Both build a blurred *base*, subtract it from the +//! pixel to get a local contrast signal, and add a multiple of that signal +//! back. The only thing that separates them is the width of the blur, and +//! that single difference is the whole of what a photographer means by the two +//! words: +//! +//! - **Clarity** works at roughly a hundredth of the frame. At that scale the +//! base is a picture of where the *subject* is, so the difference is the +//! subject's modelling — the sense of a face standing away from its +//! background, of cloud having volume. It is the "punch" control, and it is +//! also the one that produces visible halos when it is got wrong, because a +//! forty-pixel overshoot along a skyline is not a subtlety. +//! +//! - **Texture** works a decade finer, at a few pixels. At that scale the base +//! is a picture of the *surface*, so the difference is skin, fabric, bark, +//! foliage. Its overshoot is a band two or three pixels wide, which the eye +//! reads as acutance rather than as a halo — which is exactly why it can be +//! pushed much harder than clarity without looking artificial. +//! +//! # Why two nodes and not one node with two parameters +//! +//! The tempting shape is a single `local_contrast` node with a `clarity` and a +//! `texture` slider, since they share every line of machinery. It is the wrong +//! one, for four reasons that all point the same way. +//! +//! **The scale is not a parameter, it is the definition.** Neither control +//! exposes a radius, and neither should: a texture slider with a large radius +//! *is* clarity, and offering the photographer that knob would ask them to +//! re-derive the distinction the two names already make. So the radius is a +//! constant of the node — and a node whose defining constant differs is a +//! different node, not a different setting. +//! +//! **Neutrality would have to be re-implemented by hand.** The rule the whole +//! pipeline rests on is that an operation at its defaults contributes nothing: +//! no code, no uniform, no dispatch. `is_active()` gives each of these that for +//! free. Merged, the node would be active whenever *either* slider had moved, +//! and would then need an internal guard per half to avoid dispatching a +//! forty-pixel blur for a control sitting at zero — hand-writing, in one +//! place, the thing the pipeline already does everywhere. +//! +//! **There is no dispatch to save.** The usual reason to merge two operations +//! is to fuse their work. Here there is nothing to fuse: the two blurs are +//! different blurs, by definition, so a merged node costs the same four passes +//! that two nodes cost, and costs them in the same order. +//! +//! **The sidecar, the history and the reset all read better.** `clarity.amount` +//! and `texture.amount` say what they are; `local_contrast.clarity` names a +//! concept no photographer asked for in order to reach one that they did. +//! Undo says "clarity", and double-tapping clarity to reset it leaves texture +//! alone — which is what a photographer who has just tuned texture expects. +//! +//! Against all that, the cost of two nodes is one shared implementation +//! parameterised by a [`Band`], below. The `attributes:` grouping is +//! `[detail]` either way, so it offers no argument in either direction. +//! +//! # Halos, and what is done about them +//! +//! A naive unsharp mask — `c + amount * (c - blur(c))` in linear light — is +//! the single most common way this feature is got wrong, and it fails in four +//! separate ways at once. Each is addressed by a specific decision here. +//! +//! **1. Work in stops, not in levels.** The base is a Gaussian mean of *log* +//! luminance, so the detail signal is a ratio: "this pixel is 0.4 stops +//! brighter than its surroundings". In linear light the same edge produces an +//! overshoot proportional to absolute brightness, so an edge against a bright +//! sky blows out while the identical edge in shadow does nothing — and the +//! amount that looked right stops looking right the moment exposure moves. +//! Stops also make the negative direction symmetric: −50 removes exactly the +//! proportion of local contrast that +50 adds. +//! +//! **2. Soft-limit the detail signal — this is the main halo control.** The +//! signal is passed through `t * tanh(d / t)` before it is used. Below the +//! threshold the function is the identity to within a percent, so structure +//! and surface detail pass through at full strength; far above it the output +//! saturates at `t` whatever the input, so a four-stop skyline transition +//! contributes no more overshoot than a one-stop one. That is the distinction +//! between *structure* and an *edge*, drawn on amplitude rather than by an +//! edge detector — a guided or bilateral base would draw it more precisely and +//! would cost several more full-frame passes to do it. `tanh` costs one +//! instruction and has no threshold artefact, because it is smooth everywhere; +//! a hard clamp would put a visible contour along the locus where the detail +//! signal crosses `t`. +//! +//! It is also the right thing on the negative side. At amount −100 an +//! unlimited unsharp mask subtracts the whole detail signal and dissolves +//! edges into mud; limited, it removes at most `t` stops, so negative clarity +//! softens surface and modelling while leaving real edges standing. +//! +//! **3. Move luminance only, and scale the triple.** The gain is applied as +//! `c * 2^stops`, which leaves chromaticity exactly where it was. Boosting the +//! three channels independently shifts hue and saturation wherever the detail +//! signal is large — that is a *coloured* fringe along every edge, arriving +//! from a control the photographer thinks of as contrast, and it is the hardest +//! kind of halo to attribute to its cause. `apply_tone_gain` already takes this +//! position for the tonal controls, for the same reason. +//! +//! **4. Taper clarity to nothing at both ends of the range.** Clarity is +//! midtone structure by definition, and its two worst halos are at the +//! extremes: a bright sky beside a dark subject blooms, and deep shadow goes +//! to mud. A weight of `1 - (2p - 1)^2` over the perceptual tone position +//! removes exactly those, and stops a *contrast* slider from creating a blown +//! highlight by pushing a recovered value back over one. +//! +//! Texture deliberately does **not** get this taper. Skin in a highlight and +//! fabric in a shadow are precisely what the control is for, and a fine-scale +//! overshoot at either end is a two-pixel band, not a bloom. +//! +//! # Why the radius is a fraction of the frame +//! +//! [`RenderScale`] names two units, and picking the wrong one produces an +//! effect that is a different photograph on screen and in the file. These two +//! controls take [`RenderScale::frame_fraction`] — the unit a mask feather is +//! already stored in — and not [`RenderScale::source_pixels`]. +//! +//! The test is whose property the length is. Capture sharpening's radius +//! belongs to the *sensor*: it is about the lens's circle of confusion and the +//! demosaic's interpolation, both of which are facts about the file and +//! neither of which changes if the photograph is cropped. Clarity's radius +//! belongs to the *composition*: "separate the subject from its background" is +//! a statement about how much of the frame the subject occupies, and it stays +//! true when the same frame is printed large or viewed small. Crop into a +//! quarter of the frame and the subject now fills it, so the scale that models +//! it really has grown — which `frame_fraction` gives, because +//! [`crate::EditGraph::render_scale`] folds the crop in before this code runs. +//! +//! The practical consequence is that these two controls preview honestly at +//! every zoom level, which the acutance family cannot. There is no +//! [`RenderScale::resolves`] check here and no reason for one: at a small +//! render the kernel shrinks with the frame and keeps its proportions, and the +//! effect is the effect. +//! +//! Texture does eventually round to a zero-pixel kernel on a thumbnail, and +//! then contributes no pass at all. That is not the acutance family's problem +//! restated — it is the honest answer. A two-pixel surface structure is not +//! present in a 300-pixel rendering of the frame in the first place, and it +//! reappears, exactly, as soon as the view is zoomed. +//! +//! # What this costs +//! +//! Clarity's kernel is large — of the order of a hundred taps per pass at +//! preview resolution — and the two passes are the honest, exact separable +//! Gaussian rather than a sparse approximation of one. A strided kernel would +//! be several times cheaper and is deliberately not taken: undersampling an +//! image that is not band-limited aliases high-frequency content down into the +//! base, the base is then subtracted, and the aliasing arrives in the output as +//! low-frequency mottling across smooth gradients. Mottled skies are precisely +//! the artefact this control must not have. The right optimisation is a base +//! computed at reduced resolution, which needs a detail stage that can write a +//! smaller target than it reads; that is a change to [`crate::detail`], not to +//! this file. + +use std::marker::PhantomData; + +use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId}; +use crate::detail::{DetailPass, DetailStage, RenderScale}; +use crate::operation::{Affects, Helper, Operation, Uniform}; +use crate::ops::helpers; + +pub const CLARITY: OpId = OpId("clarity"); +pub const TEXTURE: OpId = OpId("texture"); + +/// The one parameter each control has. Both are called `amount`, so the +/// sidecar keys read `clarity.amount` and `texture.amount`. +pub const AMOUNT: ParamId = ParamId("amount"); + +/// How far the kernel runs, in standard deviations. +/// +/// Two, not three. A Gaussian truncated at 2σ and renormalised keeps 95.4% of +/// its mass and is still a perfectly monotone low-pass; the missing tail +/// changes the base by less than the difference between two adjacent settings +/// of the slider, and it halves the tap count of the widest pass in the +/// pipeline. +const TRUNCATION: f32 = 2.0; + +/// Everything that makes one of these two controls the control it is. +/// +/// A struct rather than four associated constants so that the differences +/// between clarity and texture can be read side by side, which is the one +/// thing a reader comes to this file to do. +pub struct Recipe { + descriptor: &'static OpDescriptor, + helpers: &'static [Helper], + /// The Gaussian's σ, as a fraction of the frame's shorter edge. + sigma: f32, + /// Where the soft limit starts to bite, in stops. See the module + /// documentation, halo control (2). + threshold: f32, + /// Stops of local contrast added at full slider travel. + gain: f32, + /// Whether the effect is tapered away from the midtones. See halo control + /// (4) — true for clarity, false for texture, and that asymmetry is + /// deliberate. + midtone_taper: bool, +} + +/// The band of spatial frequencies a control acts on. +/// +/// The type parameter of [`LocalContrast`], because the scale is the *only* +/// thing that differs between clarity and texture and it differs at compile +/// time. One implementation, two nodes, and no branch anywhere that could +/// drift. +pub trait Band: Send + Sync + 'static { + const RECIPE: Recipe; +} + +/// Clarity's band: roughly a hundredth of the frame. +pub struct Coarse; + +/// Texture's band: a decade finer, a few pixels at any size. +pub struct Fine; + +impl Band for Coarse { + const RECIPE: Recipe = Recipe { + descriptor: &CLARITY_DESCRIPTOR, + helpers: CLARITY_HELPERS, + // 1.2% of the shorter edge — about 48 px on a 4000 px frame. Wide + // enough that the base is the subject rather than the surface, narrow + // enough that the result is still local contrast and not a second + // exposure slider. + sigma: 0.012, + // A third of a stop. Clarity's whole difficulty is that a wide kernel + // sees an enormous detail signal at every real edge, so the limit has + // to bite early: at full travel the largest overshoot any edge can + // produce is a third of a stop, about 26%, before the midtone taper + // reduces it further. + threshold: 0.35, + gain: 1.0, + midtone_taper: true, + }; +} + +impl Band for Fine { + const RECIPE: Recipe = Recipe { + descriptor: &TEXTURE_DESCRIPTOR, + helpers: TEXTURE_HELPERS, + // Exactly a decade below clarity, which is what makes the two controls + // separable in use: at a ten-to-one ratio of scales, neither can + // substantially do the other's job, so a photographer setting both is + // setting two things and not the same thing twice. + sigma: 0.0012, + // Three times clarity's, because at this scale the overshoot is a band + // two or three pixels wide and the eye reads that as acutance. Limiting + // it as hard as clarity would take the crispness out of the one control + // that exists to provide it. + threshold: 1.0, + // A narrow overshoot carries less visual weight than a wide one, so + // equal numbers on the two sliders should land at comparable strength. + gain: 1.25, + midtone_taper: false, + }; +} + +static CLARITY_DESCRIPTOR: OpDescriptor = OpDescriptor { + id: CLARITY, + label: LocalizedKey("op.clarity"), + params: &[ParamDescriptor::amount("amount", "param.clarity.amount")], + attributes: &[Attribute::Detail], +}; + +static TEXTURE_DESCRIPTOR: OpDescriptor = OpDescriptor { + id: TEXTURE, + label: LocalizedKey("op.texture"), + params: &[ParamDescriptor::amount("amount", "param.texture.amount")], + attributes: &[Attribute::Detail], +}; + +/// Luminance as a position on a logarithmic scale, floored. +/// +/// Declared here rather than in `_helpers.yaml` because it is not a shared +/// idea: it exists so that the base can be a mean of *log* luminance, which is +/// the first of this file's four halo decisions and means nothing outside it. +const LOG_LUMA: Helper = Helper { + name: "log_luma", + source: "\ +// Luminance in stops, floored fourteen stops below white. +// +// The floor is what makes the logarithm safe on the values this stage +// actually receives: the intermediate is unclipped and scene-referred, so a +// pixel can be exactly zero and an out-of-gamut colour can be negative. It +// sits far enough down that no real signal is affected — a fourteen-stop +// range is more than any sensor delivers — and it turns both of those into a +// very dark pixel rather than an infinity that would propagate through the +// blur into every pixel within the kernel's reach. +fn log_luma(c: vec3) -> f32 { + return log2(max(luminance(c), 0.00006103515625)); +}", +}; + +/// How much of clarity applies at a given luminance. +const MIDTONE_WEIGHT: Helper = Helper { + name: "midtone_weight", + source: "\ +// A parabola over the perceptual tone position: one in the midtones, zero at +// both black and white. +// +// Clarity is midtone structure by definition, and this is also where two of +// its three worst halos live — a bright sky beside a dark subject blooms, and +// deep shadow turns to mud. Tapering to nothing at both ends removes them, and +// stops a control the photographer reads as `contrast` from pushing a +// recovered highlight back over one and clipping it. +// +// `tone_position` rather than the raw value, so the taper is even to the eye +// rather than crowded into the bottom of the range the way a linear weight +// would be. +fn midtone_weight(luma: f32) -> f32 { + let p = 2.0 * tone_position(luma) - 1.0; + return 1.0 - p * p; +}", +}; + +static CLARITY_HELPERS: &[Helper] = &[ + helpers::LUMINANCE, + LOG_LUMA, + helpers::TONE_POSITION, + MIDTONE_WEIGHT, +]; + +static TEXTURE_HELPERS: &[Helper] = &[helpers::LUMINANCE, LOG_LUMA]; + +/// An unsharp mask at one fixed scale. +/// +/// See the module documentation for why the scale is a type parameter rather +/// than a parameter, and why there are two nodes rather than one. +pub struct LocalContrast { + /// −100…100, exactly as the slider reports it. + amount: f32, + band: PhantomData, +} + +/// Clarity: local contrast at roughly a hundredth of the frame. +pub type Clarity = LocalContrast; + +/// Texture: local contrast a decade finer than clarity. +pub type Texture = LocalContrast; + +impl Default for LocalContrast { + fn default() -> Self { + Self { + amount: 0.0, + band: PhantomData, + } + } +} + +impl LocalContrast { + pub fn new() -> Self { + Self::default() + } + + /// Start from a slider position, for tests and presets. + pub fn with_amount(amount: f32) -> Self { + Self { + amount, + band: PhantomData, + } + } + + /// The Gaussian's σ at this render, in **render pixels**. + /// + /// The one conversion this operation performs, and the reason it happens + /// here rather than in WGSL: `frame_fraction` is named after its unit, + /// where a bare `f32` in a shader would not be. + pub fn sigma(&self, scale: RenderScale) -> f32 { + scale.frame_fraction(B::RECIPE.sigma) + } + + /// The kernel radius at this render, in render pixels. + /// + /// Exposed so a test can state what it expects without repeating the + /// rounding rule — a test that recomputed it would agree with a bug. + pub fn kernel(&self, scale: RenderScale) -> u32 { + (self.sigma(scale) * TRUNCATION).round().max(0.0) as u32 + } + + /// Stops of local contrast at this slider position. + fn gain(&self) -> f32 { + self.amount / 100.0 * B::RECIPE.gain + } + + /// The largest excursion this control can produce at its current setting, + /// in stops — the bound the soft limit guarantees. + /// + /// `gain * threshold`, because `t * tanh(d / t)` saturates at `t` however + /// violent the edge. Public because it is the one number a test can hold + /// the halo to without re-deriving the shader: whatever the picture, no + /// pixel may move further than this. See the module documentation, halo + /// control (2). + pub fn overshoot_bound(&self) -> f32 { + self.gain().abs() * B::RECIPE.threshold + } +} + +impl Operation for LocalContrast { + fn descriptor(&self) -> &'static OpDescriptor { + B::RECIPE.descriptor + } + + fn set_param(&mut self, _id: ParamId, value: f32) { + self.amount = value; + } + + fn param(&self, _id: ParamId) -> f32 { + self.amount + } + + fn is_active(&self) -> bool { + self.amount != 0.0 + } + + /// Never called: a neighbourhood operation contributes no fused fragment, + /// and `compose_full` filters it out before asking. + fn wgsl_body(&self) -> String { + String::new() + } + + fn uniforms(&self) -> Vec { + Vec::new() + } + + fn affects(&self) -> Affects { + Affects::Detail + } + + fn detail(&self) -> Option<&dyn DetailStage> { + Some(self) + } + + fn helpers(&self) -> &'static [Helper] { + B::RECIPE.helpers + } +} + +impl DetailStage for LocalContrast { + fn passes(&self, scale: RenderScale) -> Vec { + let sigma = self.sigma(scale); + let radius = self.kernel(scale); + + // A kernel that rounded to nothing is not "blur by zero" — it is a + // scale this render is too small to show. Texture reaches this on a + // thumbnail and the honest answer is to contribute no pass, which is + // also what stops a degenerate one-tap Gaussian from burning two + // dispatches to copy the image. + if radius == 0 { + return Vec::new(); + } + + // 1/σ², so the shader's inner loop is a multiply rather than a + // division per tap. + let inv_variance = 1.0 / (sigma * sigma); + let shape = vec![ + Uniform { + name: "radius", + value: radius as f32, + }, + Uniform { + name: "inv_variance", + value: inv_variance, + }, + ]; + + let mut combine = shape.clone(); + combine.push(Uniform { + name: "threshold", + value: B::RECIPE.threshold, + }); + combine.push(Uniform { + name: "gain", + value: self.gain(), + }); + + vec![ + DetailPass { + label: "base", + radius, + uniforms: shape, + wgsl: BASE_X.to_string(), + }, + DetailPass { + label: "combine", + radius, + uniforms: combine, + wgsl: combine_body(B::RECIPE.midtone_taper), + }, + ] + } +} + +/// Half of the base, along x. +/// +/// Deliberately does not touch `c`: the pass after this one needs the +/// *original* colour as well as the blur, which is what an unsharp mask is and +/// why the `aux` lane exists at all. +const BASE_X: &str = "\ +// Half of a separable Gaussian, over log luminance, along x. +// +// The colour is left exactly as it arrived. An unsharp mask needs the blur and +// the original in the same place at the same time, and the ping-pong hands +// each pass only what the pass before it wrote — so the blur travels in `aux` +// and the colour rides through untouched. See `DetailPass::wgsl`. +// +// Weights are evaluated rather than tabulated: a table would need a uniform +// array sized for the largest kernel any resolution could ask for, and `exp` +// is cheaper than the bandwidth that array would cost. +let r = i32(radius); +var sum = 0.0; +var weight = 0.0; +for (var i = -r; i <= r; i = i + 1) { + let f = f32(i); + let w = exp(-0.5 * f * f * inv_variance); + sum = sum + w * log_luma(tap(coord, vec2(i, 0))); + weight = weight + w; +} +// Normalised by the weights actually summed, not by an analytic constant, so +// truncating the Gaussian at 2σ leaves a true mean rather than a slightly dark +// one — and so a kernel clamped at the image border averages the pixels that +// exist. +aux = sum / weight;"; + +/// The second pass: finish the base along y, then apply the mask. +/// +/// Generated rather than constant because the midtone taper is present for +/// clarity and absent for texture. Emitting the line only where it applies +/// keeps texture's shader honest about not having one, and saves it a uniform +/// and two helper functions it would never call. +fn combine_body(midtone_taper: bool) -> String { + let weight = if midtone_taper { + "\n\ + // Clarity only: tapered to nothing at both ends of the range. See\n\ + // `midtone_weight` for what that is worth against a halo.\n\ + let stops = gain * shaped * midtone_weight(luminance(c));" + } else { + "\n\ + // Texture is deliberately *not* tapered towards black and white.\n\ + // Skin in a highlight and fabric in a shadow are what the control is\n\ + // for, and at this scale an overshoot is two pixels wide — acutance,\n\ + // not a bloom.\n\ + let stops = gain * shaped;" + }; + + format!( + "\ +// The other half of the base, then the unsharp mask itself. +// +// `tap_aux` reads the previous pass's log-luminance blur, while `c` is still +// the colour the colour pass produced — which is the arrangement that makes an +// unsharp mask expressible in a chain that hands on one texture per pass. +let r = i32(radius); +var sum = 0.0; +var weight = 0.0; +for (var i = -r; i <= r; i = i + 1) {{ + let f = f32(i); + let w = exp(-0.5 * f * f * inv_variance); + sum = sum + w * tap_aux(coord, vec2(0, i)); + weight = weight + w; +}} +let base = sum / weight; + +// Local contrast, in **stops**. Both terms are logarithms, so this is a ratio: +// `detail` says how much brighter this pixel is than its surroundings, and +// says it in a unit that means the same thing in a highlight and in a shadow. +// The linear-light difference an unsharp mask usually takes does not, which is +// why it blows out bright edges and does nothing to dark ones. +let detail = log_luma(c) - base; + +// The halo control. Below the threshold `tanh` is the identity to within a +// percent, so structure and surface pass through at full strength; far above +// it the output saturates at the threshold, so a four-stop edge contributes no +// more overshoot than a one-stop one. Smooth everywhere, so unlike a clamp it +// leaves no contour along the locus where the detail signal crosses it. +let shaped = threshold * tanh(detail / threshold); +{weight} + +// Applied as a scale on the whole triple, which leaves chromaticity exactly +// where it was. Boosting the channels independently would put a *coloured* +// fringe along every edge, arriving from a control the photographer reads as +// contrast — the hardest kind of halo to attribute to its cause. +c = c * exp2(stops);" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::detail::compose_detail; + use dr_types::ColourSpace; + + /// The two controls, as the graph would hold them. + fn ops(clarity: f32, texture: f32) -> Vec> { + vec![ + Box::new(Clarity::with_amount(clarity)), + Box::new(Texture::with_amount(texture)), + ] + } + + fn composed(clarity: f32, texture: f32, scale: RenderScale) -> crate::ComposedDetail { + compose_detail(&ops(clarity, texture), scale, ColourSpace::Srgb) + } + + #[test] + fn both_controls_start_neutral_and_cost_nothing() { + // The rule the whole pipeline rests on. An unedited photograph must + // not pay for a clarity slider nobody has touched — and, because these + // are the widest kernels in the pipeline, "nothing" here is a large + // amount of nothing. + assert!(!Clarity::new().is_active()); + assert!(!Texture::new().is_active()); + assert!(composed(0.0, 0.0, RenderScale::full((2000, 1500))).is_empty()); + } + + #[test] + fn one_control_moving_does_not_dispatch_the_other() { + // The concrete reason these are two nodes rather than one with two + // sliders. Merged, the node would be active whenever either had moved + // and would need a hand-written guard per half to avoid running a + // forty-pixel blur for a control sitting at zero. + let scale = RenderScale::full((2000, 1500)); + let only_clarity = composed(50.0, 0.0, scale); + assert_eq!(only_clarity.len(), 2, "one operation, two passes"); + assert!(only_clarity.passes.iter().all(|p| p.label.starts_with("clarity/"))); + + let both = composed(50.0, 50.0, scale); + assert_eq!(both.len(), 4); + } + + #[test] + fn the_two_controls_differ_by_a_decade_of_scale() { + // The whole point of there being two of them. If these ever converge, + // one of the controls has stopped doing its job and the second slider + // has become a duplicate of the first. + let scale = RenderScale::full((4000, 3000)); + let clarity = Clarity::with_amount(100.0).kernel(scale); + let texture = Texture::with_amount(100.0).kernel(scale); + // 1.2% of the *shorter* edge — 3000, not 4000 — and two sigmas wide. + assert_eq!(clarity, 72); + assert_eq!(texture, 7, "a decade finer"); + assert!( + clarity >= texture * 8, + "clarity {clarity} and texture {texture} are not separable scales" + ); + } + + #[test] + fn a_radius_is_a_fraction_of_the_frame_and_not_a_count_of_source_pixels() { + // TRACES: FR-DSP-1 — the decision this whole file's units rest on. + // + // Clarity is compositional: "separate the subject from its background" + // is a statement about how much of the frame the subject occupies, and + // it stays true at every size the frame is rendered at. So the kernel + // must cover the same *proportion* of the picture on a proxy as in the + // export, which is what `frame_fraction` gives and what + // `source_pixels` would not. + let sizes = [(400u32, 300u32), (2000, 1500), (6000, 4500)]; + let proportions: Vec = sizes + .iter() + .map(|&(w, h)| { + let scale = RenderScale::full((w, h)); + Clarity::with_amount(60.0).kernel(scale) as f32 / w.min(h) as f32 + }) + .collect(); + for p in &proportions { + assert!( + (p - 0.024).abs() < 0.002, + "the kernel drifted from its declared fraction: {proportions:?}" + ); + } + + // And the contrast with the other unit, stated rather than implied: on + // a one-third proxy a source-pixel radius *shrinks* to a third of the + // proportion it had, which is the bug this choice avoids. + let proxy = RenderScale::new((2000, 1500), (6000, 4500)); + let clarity = Clarity::with_amount(60.0); + assert_eq!(clarity.kernel(proxy), clarity.kernel(RenderScale::full((2000, 1500)))); + assert!( + proxy.source_pixels(96.0) < 40.0, + "the same length in the other unit would have collapsed" + ); + } + + #[test] + fn texture_stops_rather_than_lying_when_the_render_is_too_small() { + // A two-pixel surface structure is not present in a 300-pixel + // rendering of the frame, so the honest thing is to contribute no + // pass. Unlike the acutance family this is not an approximation being + // hidden: zoom in and the kernel comes back, exactly. + let thumbnail = RenderScale::full((160, 120)); + assert_eq!(Texture::with_amount(100.0).kernel(thumbnail), 0); + assert!(Texture::with_amount(100.0).passes(thumbnail).is_empty()); + + // Clarity is a hundred times wider and survives, which is what a + // thumbnail should show: the modelling, not the surface. + assert!(Clarity::with_amount(100.0).kernel(thumbnail) > 0); + assert_eq!(Clarity::with_amount(100.0).passes(thumbnail).len(), 2); + } + + #[test] + fn the_first_pass_hands_the_original_colour_to_the_second() { + // The property that makes an unsharp mask expressible in a chain that + // passes on one texture per pass. If the blur pass ever writes `c`, + // the combining pass has nothing to subtract the base *from* and the + // operation silently becomes a blur. + let passes = Clarity::with_amount(50.0).passes(RenderScale::full((2000, 1500))); + let base = &passes[0]; + let combine = &passes[1]; + + assert!(base.wgsl.contains("aux = sum / weight;")); + assert!( + !base.wgsl.contains("c = "), + "the blur pass must leave the colour alone: {}", + base.wgsl + ); + assert!( + combine.wgsl.contains("tap_aux("), + "the combining pass must read the blur out of the scratch lane" + ); + assert!(combine.wgsl.contains("log_luma(c) - base")); + } + + #[test] + fn the_declared_radius_is_the_halo_a_tile_would_need() { + // ARCH §5.3 grows a tile by the widest reach of the pass computing it, + // and nothing can infer that from the WGSL because the offsets come + // from a uniform. An understated radius shows as a seam at every tile + // boundary — an artefact that reads as a driver bug. + let scale = RenderScale::full((2000, 1500)); + let composed = composed(50.0, 50.0, scale); + let clarity = Clarity::with_amount(50.0).kernel(scale); + assert_eq!(composed.radius(), clarity, "the widest pass sets the halo"); + for pass in &composed.passes { + assert!(pass.radius > 0, "{} declared no reach", pass.label); + } + } + + #[test] + fn the_halo_limit_is_in_the_shader_and_bounds_the_overshoot() { + // The single most common way this feature is got wrong. `tanh` + // saturates at the threshold, so the largest overshoot a full-travel + // slider can produce is `gain * threshold` stops however violent the + // edge — a bound that holds by construction rather than by tuning. + let combine = &Clarity::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; + assert!(combine.wgsl.contains("threshold * tanh(detail / threshold)")); + + let bound = |u: &[Uniform]| { + let get = |n| u.iter().find(|x| x.name == n).unwrap().value; + get("gain").abs() * get("threshold") + }; + // A third of a stop for clarity, before the midtone taper takes more + // off; a little over a stop for texture, whose overshoot is two pixels + // wide and reads as acutance. + assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6); + let texture = &Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; + assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6); + } + + #[test] + fn only_clarity_tapers_towards_black_and_white() { + // The asymmetry is the deliberate part: texture must work on skin in a + // highlight and fabric in a shadow, which is exactly where clarity + // must not. + let scale = RenderScale::full((2000, 1500)); + let clarity = &Clarity::with_amount(50.0).passes(scale)[1]; + let texture = &Texture::with_amount(50.0).passes(scale)[1]; + assert!(clarity.wgsl.contains("midtone_weight(luminance(c))")); + assert!(!texture.wgsl.contains("midtone_weight")); + + // And texture does not carry the helpers it would need for one, so its + // shader says what it does rather than merely not calling it. + assert!(Texture::new().helpers().iter().any(|h| h.name == "log_luma")); + assert!(!Texture::new() + .helpers() + .iter() + .any(|h| h.name == "midtone_weight")); + assert!(Clarity::new() + .helpers() + .iter() + .any(|h| h.name == "midtone_weight")); + } + + #[test] + fn the_amount_is_symmetric_about_neutral() { + // Working in stops is what buys this: −50 removes exactly the + // proportion of local contrast that +50 adds, at every brightness. + // In linear light it would not, and the pair of settings that looked + // balanced would depend on exposure. + let scale = RenderScale::full((2000, 1500)); + let up = Clarity::with_amount(50.0).passes(scale); + let down = Clarity::with_amount(-50.0).passes(scale); + let gain = |p: &[DetailPass]| { + p[1].uniforms + .iter() + .find(|u| u.name == "gain") + .unwrap() + .value + }; + assert!((gain(&up) + gain(&down)).abs() < 1e-6); + // Same kernel either way — the direction is a sign, not a scale. + assert_eq!(up[0].radius, down[0].radius); + } + + #[test] + fn every_pass_declares_a_uniform_block_the_gpu_will_accept() { + // A uniform struct whose size is not a multiple of 16 is rejected + // outright by the WGSL uniform address space rules, and arrives as a + // compilation failure against generated source. + for pass in composed(70.0, -40.0, RenderScale::full((1600, 1200))).passes { + assert_eq!(pass.uniforms.len() % 4, 0, "{}", pass.label); + assert!( + pass.uniforms.iter().all(|v| v.is_finite()), + "{} uploaded a non-finite uniform", + pass.label + ); + } + } + + #[test] + fn the_parameter_round_trips_under_its_own_id() { + // Mechanical, and exactly why it is worth checking: a `set_param` that + // read the wrong field would look perfect and silently break the + // sidecar. + let mut op = Clarity::new(); + op.set_param(AMOUNT, -37.0); + assert_eq!(op.param(AMOUNT), -37.0); + assert_eq!(op.descriptor().id, CLARITY); + assert_eq!(Texture::new().descriptor().id, TEXTURE); + } +} diff --git a/core/dr-pipeline/src/ops/mod.rs b/core/dr-pipeline/src/ops/mod.rs index 34362cb..5c4bd5a 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -44,12 +44,16 @@ pub mod aberration; pub mod colour_mixer; pub mod curve; pub mod distortion; +pub mod local_contrast; pub mod vignetting; pub use aberration::Aberration; pub use colour_mixer::ColourMixer; pub use curve::ToneCurve; pub use distortion::Distortion; +// Clarity and texture are one implementation at two scales; see the module's +// documentation for why that is two nodes and not one. +pub use local_contrast::{Clarity, Texture}; pub use vignetting::Vignetting; // The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by From cd8750462f096df6457ca84f2b3fcd69a0cec8b5 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:01:18 +0200 Subject: [PATCH 09/27] WIP: EXIF metadata on export Checkpoint committed by the coordinator, not by the authoring agent: the session hit its API limit mid-task and left this work uncommitted. Committed so it survives, NOT because it is finished - expect failing tests and half-applied changes. The agent resumes from here. --- core/dr-decode/src/lib.rs | 76 +++- core/dr-decode/src/locate.rs | 319 +++++++++++++++ core/dr-export/src/encode.rs | 697 +++++++++++++++++++++++++++++++-- core/dr-export/src/exif.rs | 518 ++++++++++++++++++++++++ core/dr-export/src/lib.rs | 41 +- core/dr-export/src/metadata.rs | 106 +++++ core/dr-types/src/lib.rs | 45 +++ core/dr-types/src/settings.rs | 41 ++ 8 files changed, 1797 insertions(+), 46 deletions(-) create mode 100644 core/dr-export/src/exif.rs create mode 100644 core/dr-export/src/metadata.rs diff --git a/core/dr-decode/src/lib.rs b/core/dr-decode/src/lib.rs index 66892ee..a591fad 100644 --- a/core/dr-decode/src/lib.rs +++ b/core/dr-decode/src/lib.rs @@ -21,8 +21,8 @@ pub mod profile; pub use base_curve::BaseCurve; pub use error::DecodeError; pub use locate::{ - defects, is_complete_jpeg, locate_preview, BadLine, BadPixel, Defects, PreviewLocation, - HEADER_BYTES, + defects, is_complete_jpeg, jpeg_metadata, locate_preview, tiff_metadata, BadLine, BadPixel, + Defects, PreviewLocation, HEADER_BYTES, }; pub use preview::{ decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize, @@ -30,7 +30,7 @@ pub use preview::{ }; pub use profile::CameraProfile; -use dr_types::{Format, Orientation}; +use dr_types::{Format, Location, Orientation}; /// Capture metadata read from a file header. #[derive(Debug, Clone, Default, PartialEq)] @@ -68,6 +68,27 @@ pub struct Metadata { /// to where it was taken, so without this a shoot in Tokyo displays on the /// wrong day in Paris. pub captured_offset: Option, + /// TRACES: FR-EXP-8 + /// Who made the photograph (EXIF `Artist`, 0x013B). + /// + /// Read for the sake of exporting it again: a photographer who set a byline + /// in-camera set it so that it would still be there in the copy they hand + /// over, and an export that dropped it would be quietly removing the one + /// piece of metadata that says whose work this is. + pub artist: Option, + /// TRACES: FR-EXP-8 + /// The rights statement (EXIF `Copyright`, 0x8298). + pub copyright: Option, + /// TRACES: FR-EXP-8 + /// Where the shutter fired (the EXIF GPS directory, 0x8825). + /// + /// **Read, but treated as radioactive downstream.** This is the field + /// FR-EXP-8's strip option exists for, and the export path drops it unless + /// the user has explicitly said otherwise (`ExportSettings::strip_location` + /// defaults to on). Parsing it here rather than refusing to look is what + /// makes "keep my coordinates" possible at all, and what lets the exporter + /// prove the field is gone rather than hope it was never present. + pub location: Option, } /// Decoded sensor data, before demosaic. @@ -302,6 +323,10 @@ pub fn metadata(bytes: &[u8]) -> Result { .as_deref() .or(exif.offset_time.as_deref()) .and_then(parse_exif_offset), + // TRACES: FR-EXP-8 + artist: exif.artist.clone().filter(|s| !s.trim().is_empty()), + copyright: exif.copyright.clone().filter(|s| !s.trim().is_empty()), + location: exif.gps.as_ref().and_then(rawler_location), }; // rawler reports no capture time for some TIFF-derived files whose tag is @@ -322,12 +347,57 @@ pub fn metadata(bytes: &[u8]) -> Result { out.iso = out.iso.or(fallback.iso); out.lens = out.lens.take().or(fallback.lens); out.orientation = out.orientation.or(fallback.orientation); + // TRACES: FR-EXP-8 + // Filled from the same walk for the same reason: a file rawler + // answered short on is one whose byline and rights statement would + // otherwise be dropped at export, and both sit in the IFD this has + // already read. + out.artist = out.artist.take().or(fallback.artist); + out.copyright = out.copyright.take().or(fallback.copyright); + out.location = out.location.or(fallback.location); } } Ok(out) } +/// TRACES: FR-EXP-8 +/// rawler's GPS directory as a position. +/// +/// The three-rational form is the tag's, not a position's: degrees, minutes +/// and seconds, each a fraction, with the hemisphere in a separate letter. +/// Everything downstream wants a number it can compare and write back, so the +/// conversion happens once, here. +fn rawler_location(gps: &rawler::exif::ExifGPS) -> Option { + /// A `Rational` as a float, with a zero denominator refused rather than + /// divided by — some bodies write `0/0` into an unfilled slot. + fn ratio(r: &rawler::formats::tiff::Rational) -> Option { + (r.d != 0).then(|| r.n as f64 / r.d as f64) + } + + fn degrees(dms: &[rawler::formats::tiff::Rational; 3], reference: Option<&String>) -> Option { + let d = ratio(&dms[0])? + ratio(&dms[1])? / 60.0 + ratio(&dms[2])? / 3600.0; + // South and west are stored as positive magnitudes with a letter. + let negative = matches!( + reference.map(|s| s.trim().to_ascii_uppercase()).as_deref(), + Some("S") | Some("W") + ); + Some(if negative { -d } else { d }) + } + + let latitude = degrees(gps.gps_latitude.as_ref()?, gps.gps_latitude_ref.as_ref())?; + let longitude = degrees(gps.gps_longitude.as_ref()?, gps.gps_longitude_ref.as_ref())?; + // Reference 1 means below sea level; the altitude itself is unsigned. + let altitude = gps.gps_altitude.as_ref().and_then(ratio).map(|a| { + if gps.gps_altitude_ref == Some(1) { + -a + } else { + a + } + }); + Location::new(latitude, longitude, altitude) +} + /// TRACES: FR-CAT-5 | FR-DEV-3h /// Read just the stored orientation, from a file header. /// diff --git a/core/dr-decode/src/locate.rs b/core/dr-decode/src/locate.rs index 025ab7b..465cc22 100644 --- a/core/dr-decode/src/locate.rs +++ b/core/dr-decode/src/locate.rs @@ -287,6 +287,16 @@ impl<'a> TiffReader<'a> { /// its offset. fn scalar(&self, e: &Entry) -> Option { match e.kind { + // BYTE, inline when count is 1. The GPS directory's altitude + // reference is one of these, and it is the difference between a + // hilltop and a position 400 m under the Dead Sea. + 1 if e.count == 1 => Some(if self.little_endian { + e.value & 0xFF + } else { + // The value field is left-justified whatever the width, so a + // big-endian byte sits in the *top* octet. + e.value >> 24 + }), // SHORT, inline when count is 1. 3 if e.count == 1 => Some(if self.little_endian { e.value & 0xFFFF @@ -303,6 +313,35 @@ impl<'a> TiffReader<'a> { } } + /// TRACES: FR-EXP-8 + /// One RATIONAL from an entry, as a number. + /// + /// A rational is eight bytes, so it never fits the four-byte value field + /// and is always read through the offset — which is why `index` is + /// meaningful: the GPS directory stores latitude as three of them in a + /// row. + /// + /// A zero denominator yields `None` rather than an infinity. Cameras do + /// write `0/0` into slots they had nothing for, and a shutter speed of + /// `inf` propagated into an exported file is worse than a missing one. + fn rational(&self, e: &Entry, index: u32) -> Option { + // 5 is RATIONAL (two unsigned longs); 10 is SRATIONAL (two signed). + if (e.kind != 5 && e.kind != 10) || index >= e.count { + return None; + } + let at = (e.value as usize).checked_add(index as usize * 8)?; + let n = read_u32(self.data, at, self.little_endian)?; + let d = read_u32(self.data, at + 4, self.little_endian)?; + if d == 0 { + return None; + } + Some(if e.kind == 10 { + n as i32 as f64 / d as i32 as f64 + } else { + n as f64 / d as f64 + }) + } + /// An ASCII entry's string value. /// /// EXIF strings are NUL-terminated and often padded, and camera vendors @@ -444,6 +483,19 @@ pub fn tiff_metadata(tiff_data: &[u8]) -> Result md.model = r.ascii(e), exif_tag::LENS_MODEL => md.lens = r.ascii(e), exif_tag::ISO => md.iso = r.scalar(e), + exif_tag::ARTIST => md.artist = r.ascii(e), + exif_tag::COPYRIGHT => md.copyright = r.ascii(e), + exif_tag::EXPOSURE_TIME => md.shutter = r.rational(e, 0).map(|v| v as f32), + exif_tag::FNUMBER => md.aperture = r.rational(e, 0).map(|v| v as f32), + exif_tag::FOCAL_LENGTH => md.focal_length = r.rational(e, 0).map(|v| v as f32), 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 @@ -578,6 +664,49 @@ fn read_exif_entries( } } +/// TRACES: FR-EXP-8 +/// A GPS directory's entries as a position. +/// +/// Both coordinates or nothing: a latitude without a longitude is not half a +/// position, it is no position, and half of one written into an export would +/// be a coordinate on the Greenwich meridian. +fn read_gps_entries(r: &TiffReader, entries: &[Entry]) -> Option { + let find = |tag: u16| entries.iter().find(|e| e.tag == tag); + + // Degrees, minutes and seconds, each its own rational — and each of the + // three optional in practice, since a body that fixed only to the minute + // still writes the entry. + let degrees = |tag: u16, ref_tag: u16| -> Option { + let e = find(tag)?; + let d = r.rational(e, 0)? + r.rational(e, 1).unwrap_or(0.0) / 60.0 + + r.rational(e, 2).unwrap_or(0.0) / 3600.0; + // The magnitude is unsigned; the hemisphere is a letter beside it. + let south_or_west = find(ref_tag) + .and_then(|e| r.ascii(e)) + .map(|s| { + let s = s.trim().to_ascii_uppercase(); + s == "S" || s == "W" + }) + .unwrap_or(false); + Some(if south_or_west { -d } else { d }) + }; + + let latitude = degrees(gps_tag::LATITUDE, gps_tag::LATITUDE_REF)?; + let longitude = degrees(gps_tag::LONGITUDE, gps_tag::LONGITUDE_REF)?; + let altitude = find(gps_tag::ALTITUDE) + .and_then(|e| r.rational(e, 0)) + .map(|a| { + let below = find(gps_tag::ALTITUDE_REF).and_then(|e| r.scalar(e)) == Some(1); + if below { + -a + } else { + a + } + }); + + dr_types::Location::new(latitude, longitude, altitude) +} + /// Whether a byte slice is a complete JPEG. /// /// A truncated JPEG decodes to a partial image rather than an error — the @@ -968,6 +1097,196 @@ mod tests { assert_eq!(md.model.as_deref(), Some("CanoScan 9000F Mark II")); } + /// A JPEG whose EXIF carries a GPS directory, built by hand. + /// + /// The offsets are computed rather than written out because the whole + /// point of the exercise is that they are consistent: a GPS directory is + /// three levels of indirection — the main IFD points at it, and each + /// coordinate points at three rationals somewhere else again. + /// + /// `lat`/`lon` are `(degrees, minutes, hundredths-of-a-second)` and the + /// refs are the hemisphere letters, exactly as a camera writes them. + fn jpeg_with_gps( + lat: (u32, u32, u32), + lat_ref: u8, + lon: (u32, u32, u32), + lon_ref: u8, + altitude: Option<(u32, u8)>, + ) -> Vec { + // One entry in IFD0 (the GPS pointer), so the blob after it starts at + // the header (8) + count (2) + one entry (12) + the next-IFD link (4). + const GPS_IFD: u32 = 8 + 2 + 12 + 4; + let entries: u32 = if altitude.is_some() { 6 } else { 5 }; + // Where the rationals live: after the GPS directory itself. + let heap = GPS_IFD + 2 + entries * 12 + 4; + + let mut gps: Vec<(u16, u16, u32, u32)> = vec![ + (gps_tag::LATITUDE_REF, 2, 2, u32::from(lat_ref)), + (gps_tag::LATITUDE, 5, 3, heap), + (gps_tag::LONGITUDE_REF, 2, 2, u32::from(lon_ref)), + (gps_tag::LONGITUDE, 5, 3, heap + 24), + ]; + if let Some((_, reference)) = altitude { + gps.push((gps_tag::ALTITUDE_REF, 1, 1, u32::from(reference))); + gps.push((gps_tag::ALTITUDE, 5, 1, heap + 48)); + } + + let mut extra = Vec::new(); + extra.extend_from_slice(&(gps.len() as u16).to_le_bytes()); + for (tag, kind, count, value) in &gps { + extra.extend_from_slice(&tag.to_le_bytes()); + extra.extend_from_slice(&kind.to_le_bytes()); + extra.extend_from_slice(&count.to_le_bytes()); + extra.extend_from_slice(&value.to_le_bytes()); + } + extra.extend_from_slice(&0u32.to_le_bytes()); + + let mut rational = |n: u32, d: u32| { + extra.extend_from_slice(&n.to_le_bytes()); + extra.extend_from_slice(&d.to_le_bytes()); + }; + for (n, d) in [(lat.0, 1), (lat.1, 1), (lat.2, 100)] { + rational(n, d); + } + for (n, d) in [(lon.0, 1), (lon.1, 1), (lon.2, 100)] { + rational(n, d); + } + if let Some((metres, _)) = altitude { + rational(metres, 1); + } + + jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra) + } + + #[test] + fn a_gps_directory_becomes_signed_degrees() { + // TRACES: FR-EXP-8 + // 48° 51' 29.52" N, 2° 17' 40.2" E — the Eiffel Tower. Reading this + // correctly is what makes stripping it meaningful: a parser that + // silently failed would make the export path look private when it was + // only ignorant. + let jpeg = jpeg_with_gps((48, 51, 2952), b'N', (2, 17, 4020), b'E', Some((35, 0))); + let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix"); + assert!((loc.latitude - 48.858200).abs() < 1e-5, "{loc:?}"); + assert!((loc.longitude - 2.294500).abs() < 1e-5, "{loc:?}"); + assert_eq!(loc.altitude, Some(35.0)); + } + + #[test] + fn the_hemisphere_letters_are_applied_not_ignored() { + // The failure this catches puts Sydney in the North Atlantic: the + // magnitudes are identical and only the letters differ. + let jpeg = jpeg_with_gps((33, 51, 3500), b'S', (151, 12, 3600), b'E', None); + let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix"); + assert!(loc.latitude < 0.0, "southern latitude must be negative"); + assert!(loc.longitude > 0.0, "eastern longitude must be positive"); + assert!(loc.altitude.is_none()); + } + + #[test] + fn a_below_sea_level_altitude_keeps_its_sign() { + // Reference 1 means below sea level; the altitude itself is unsigned, + // so dropping the reference turns the Dead Sea into a hilltop. + let jpeg = jpeg_with_gps((31, 33, 0), b'N', (35, 28, 0), b'E', Some((430, 1))); + let loc = jpeg_metadata(&jpeg).expect("EXIF").location.expect("a fix"); + assert_eq!(loc.altitude, Some(-430.0)); + } + + #[test] + fn a_latitude_with_no_longitude_is_not_half_a_position() { + // Half a coordinate written into a file would be a pin on the + // Greenwich meridian, which is worse than no pin. + const GPS_IFD: u32 = 8 + 2 + 12 + 4; + let heap = GPS_IFD + 2 + 12 + 4; + let mut extra = Vec::new(); + extra.extend_from_slice(&1u16.to_le_bytes()); + for (tag, kind, count, value) in [(gps_tag::LATITUDE, 5u16, 3u32, heap)] { + extra.extend_from_slice(&tag.to_le_bytes()); + extra.extend_from_slice(&kind.to_le_bytes()); + extra.extend_from_slice(&count.to_le_bytes()); + extra.extend_from_slice(&value.to_le_bytes()); + } + extra.extend_from_slice(&0u32.to_le_bytes()); + for (n, d) in [(48u32, 1u32), (51, 1), (2952, 100)] { + extra.extend_from_slice(&n.to_le_bytes()); + extra.extend_from_slice(&d.to_le_bytes()); + } + + let jpeg = jpeg_with_exif(&[(gps_tag::POINTER, 4, 1, GPS_IFD)], &extra); + assert!(jpeg_metadata(&jpeg).expect("EXIF").location.is_none()); + } + + #[test] + fn the_byline_and_the_rights_statement_are_read() { + // TRACES: FR-EXP-8 + // Both live in the main IFD, and both are the half of FR-EXP-8 that + // must *survive* an export rather than be removed by it. + let artist = b"Duncan Tourolle\0"; + let copyright = b"(c) 2026 Duncan Tourolle. All rights reserved.\0"; + let base = 8 + 2 + 2 * 12 + 4; + let mut extra = Vec::new(); + extra.extend_from_slice(artist); + extra.extend_from_slice(copyright); + + let jpeg = jpeg_with_exif( + &[ + (exif_tag::ARTIST, 2, artist.len() as u32, base), + ( + exif_tag::COPYRIGHT, + 2, + copyright.len() as u32, + base + artist.len() as u32, + ), + ], + &extra, + ); + let md = jpeg_metadata(&jpeg).expect("EXIF"); + assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle")); + assert_eq!( + md.copyright.as_deref(), + Some("(c) 2026 Duncan Tourolle. All rights reserved.") + ); + } + + #[test] + fn exposure_rationals_are_read_from_a_jpeg() { + // rawler fills these for a RAW; a camera JPEG has nothing behind it + // but this reader, and an export that lost the shutter speed lost it + // for good. + let base = 8 + 2 + 3 * 12 + 4; + let mut extra = Vec::new(); + for (n, d) in [(1u32, 250u32), (28, 10), (850, 10)] { + extra.extend_from_slice(&n.to_le_bytes()); + extra.extend_from_slice(&d.to_le_bytes()); + } + + let jpeg = jpeg_with_exif( + &[ + (exif_tag::EXPOSURE_TIME, 5, 1, base), + (exif_tag::FNUMBER, 5, 1, base + 8), + (exif_tag::FOCAL_LENGTH, 5, 1, base + 16), + ], + &extra, + ); + let md = jpeg_metadata(&jpeg).expect("EXIF"); + assert_eq!(md.shutter, Some(1.0 / 250.0)); + assert_eq!(md.aperture, Some(2.8)); + assert_eq!(md.focal_length, Some(85.0)); + } + + #[test] + fn a_zero_denominator_is_no_reading_rather_than_an_infinity() { + // Bodies do write `0/0` into a slot they had nothing for, and `inf` + // seconds carried into an exported file is worse than a gap. + let base = 8 + 2 + 12 + 4; + let mut extra = Vec::new(); + extra.extend_from_slice(&0u32.to_le_bytes()); + extra.extend_from_slice(&0u32.to_le_bytes()); + + let jpeg = jpeg_with_exif(&[(exif_tag::EXPOSURE_TIME, 5, 1, base)], &extra); + assert_eq!(jpeg_metadata(&jpeg).expect("EXIF").shutter, None); + } + #[test] fn a_marker_walk_does_not_run_off_a_truncated_file() { // Untrusted input (NFR-SEC-1): a length field claiming more than the diff --git a/core/dr-export/src/encode.rs b/core/dr-export/src/encode.rs index 6495a33..01f96d2 100644 --- a/core/dr-export/src/encode.rs +++ b/core/dr-export/src/encode.rs @@ -21,38 +21,78 @@ //! //! # Metadata //! -//! Nothing is written. `strip_location` defaults to on (FR-EXP-8) and this -//! satisfies it in the strongest possible way: there is no EXIF block, so -//! there is no GPS tag, no serial number, and no lens history in the file -//! that leaves the machine. +//! Written the same way the profile is: in the place each container puts it — +//! a JPEG APP1 segment behind `Exif\0\0`, a PNG `eXIf` chunk, and for TIFF the +//! image directory itself, since a TIFF's own IFD *is* EXIF and a nested block +//! would be a second, contradictory copy. //! -//! The other half of FR-EXP-8 — *retaining* camera and copyright metadata -//! when the user asks for it — is not implemented, and cannot be faked by -//! omission. It needs the source's EXIF carried through `dr-decode` and -//! re-serialised here, which is a piece of work in its own right and belongs -//! with the batch-export path that would make it worth having. +//! What may be written is decided before the bytes are: [`SourceMetadata`] is +//! an allowlist of parsed fields rather than a copy of the source's block, and +//! `sanitised` empties the location out of it unless the user asked otherwise. +//! By the time any function below runs there is no privacy decision left to +//! make, which is deliberate — the alternative is four encoders each of which +//! could disagree with the others about what a setting meant. +//! +//! Two settings govern it and they are not the same question (FR-EXP-8). +//! `retain_metadata` decides whether the copy says what took the photograph +//! and who owns it; off, nothing at all is written and the file is as bare as +//! this module used to make every export. `strip_location` decides whether it +//! says where, and defaults to on — so the ordinary export carries the camera, +//! the lens, the capture time and the copyright, and carries no coordinates. +//! +//! Absence, not blanking. A stripped export has no GPS directory: not one +//! full of zeroes, which would still tell a reader that this file had a fix +//! and that somebody removed it. use dr_types::{ExportFormat, ExportSettings}; -use crate::{icc, ExportError}; +use crate::metadata::SourceMetadata; +use crate::{exif, icc, ExportError}; /// Encode a resized, sharpened RGBA buffer to the requested format. /// /// The buffer is already encoded into `settings.colour_space` — that happened /// in the shader, at the only point where the unclipped colour still existed. /// All that is left here is to say so. +/// +/// `source` is what the file being exported was read from, or `None` where the +/// caller has none — a frame assembled rather than decoded. It is sanitised +/// here, once, before any encoder sees it. pub fn encode( rgba: &[u8], width: u32, height: u32, settings: &ExportSettings, + source: Option<&SourceMetadata>, ) -> Result, ExportError> { let profile = icc::profile(settings.colour_space); + + // TRACES: FR-EXP-8 + // The one place the settings are consulted. Retention off means the + // `None` propagates and every encoder below writes the bare file it always + // did; retention on means what travels is the sanitised copy, which has + // already lost the location unless the user turned stripping off. + let carried = source + .filter(|_| settings.retain_metadata) + .map(|m| m.sanitised(settings.strip_location)); + // JPEG and PNG take a finished block; TIFF writes the tags into its own + // directory and needs the fields. + let block = carried + .as_ref() + .and_then(|m| exif::block(m, width, height)); + match settings.format { - ExportFormat::Jpeg => jpeg(rgba, width, height, settings.quality, &profile), - ExportFormat::Png => png(rgba, width, height, &profile), - ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile), - ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile), + ExportFormat::Jpeg => jpeg( + rgba, + width, + height, + settings.quality, + &profile, + block.as_deref(), + ), + ExportFormat::Png => png(rgba, width, height, &profile, block.as_deref()), + ExportFormat::Tiff8 => tiff8(rgba, width, height, &profile, carried.as_ref()), + ExportFormat::Tiff16 => tiff16(rgba, width, height, &profile, carried.as_ref()), other => Err(ExportError::FormatUnsupported(other)), } } @@ -72,9 +112,20 @@ fn jpeg( height: u32, quality: u8, profile: &[u8], + exif: Option<&[u8]>, ) -> Result, ExportError> { let mut bytes = Vec::new(); let mut encoder = jpeg_encoder::Encoder::new(&mut bytes, quality); + // TRACES: FR-EXP-8 + // Before the profile, because segments are written in the order they are + // added and EXIF conventionally comes first — APP1 then APP2. Readers that + // stop at the first APP2 they find would otherwise have to walk past the + // profile to reach the capture data. + if let Some(exif) = exif { + encoder + .add_exif_metadata(exif) + .map_err(|e| ExportError::Encode(e.to_string()))?; + } // Splits across APP2 segments itself if it has to. The profiles this crate // generates fit in one, but the branch is the encoder's rather than ours. encoder @@ -91,7 +142,13 @@ fn jpeg( Ok(bytes) } -fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { +fn png( + rgba: &[u8], + width: u32, + height: u32, + profile: &[u8], + exif: Option<&[u8]>, +) -> Result, ExportError> { let mut bytes = Vec::new(); { // Built through `Info` rather than the setters, because the profile is @@ -103,6 +160,13 @@ fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, info.color_type = png::ColorType::Rgb; info.bit_depth = png::BitDepth::Eight; info.icc_profile = Some(std::borrow::Cow::Borrowed(profile)); + // TRACES: FR-EXP-8 + // PNG's `eXIf` chunk holds the same TIFF structure a JPEG's APP1 does, + // minus the `Exif\0\0` marker — the chunk name has already said what + // it is. Standardised in PNG's third edition and read by every current + // viewer; older ones ignore an unknown ancillary chunk, which is the + // correct failure. + info.exif_metadata = exif.map(std::borrow::Cow::Borrowed); let encoder = png::Encoder::with_info(&mut bytes, info) .map_err(|e| ExportError::Encode(e.to_string()))?; @@ -119,15 +183,16 @@ fn png(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, Ok(bytes) } -/// The ICC profile as a TIFF tag value. +/// Bytes whose TIFF field type is `UNDEFINED` (7). /// -/// A newtype only because the tag's field type is `UNDEFINED` (7) and the -/// `tiff` crate maps a plain `&[u8]` to `BYTE` (1). Both are single bytes and -/// most readers do not look, but libtiff declares `TIFFTAG_ICCPROFILE` as -/// undefined and a strict reader is entitled to agree with it. -struct IccTag<'a>(&'a [u8]); +/// A newtype only because the `tiff` crate maps a plain `&[u8]` to `BYTE` (1). +/// Both are single bytes and most readers do not look, but libtiff declares +/// `TIFFTAG_ICCPROFILE` as undefined and a strict reader is entitled to agree +/// with it. `ExifVersion` is the same shape for a different reason: it is four +/// characters that are deliberately not a string. +struct Undefined<'a>(&'a [u8]); -impl tiff::encoder::TiffValue for IccTag<'_> { +impl tiff::encoder::TiffValue for Undefined<'_> { const BYTE_LEN: u8 = 1; const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED; @@ -140,19 +205,85 @@ impl tiff::encoder::TiffValue for IccTag<'_> { } } +/// TRACES: FR-EXP-8 +/// A string as an EXIF `ASCII` value. +/// +/// The `tiff` crate's own `str` value *rejects* anything non-ASCII, which +/// would turn a copyright line reading `© 2026 Frédéric` into a failed export +/// — the file not written at all, over a character. Cameras and every other +/// editor write UTF-8 into these fields regardless of what the 1992 +/// specification says, `dr-decode` reads them back with `from_utf8_lossy`, and +/// a mangled accent is a far better outcome than a refusal. So the bytes go +/// through verbatim with the terminating NUL the type requires. +struct Ascii<'a>(&'a str); + +impl tiff::encoder::TiffValue for Ascii<'_> { + const BYTE_LEN: u8 = 1; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::ASCII; + + fn count(&self) -> usize { + // The NUL is part of the count, and a reader that trusts the count + // over the terminator reads one character short without it. + self.0.len() + 1 + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + let mut out = self.0.as_bytes().to_vec(); + out.push(0); + std::borrow::Cow::Owned(out) + } +} + +/// TRACES: FR-EXP-8 +/// Several `RATIONAL`s in one tag — a GPS coordinate is three. +/// +/// The bytes are **native-endian** rather than little-endian, unlike +/// everything `exif.rs` writes. That is not an inconsistency: the `tiff` crate +/// writes the file in the host's byte order and stamps the header to match, so +/// a value that forced little-endian would be read back byte-swapped on a +/// big-endian machine. `exif.rs` builds its own header and so chooses its own +/// order; here the container has already chosen. +struct Rationals<'a>(&'a [(u32, u32)]); + +impl tiff::encoder::TiffValue for Rationals<'_> { + const BYTE_LEN: u8 = 8; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::RATIONAL; + + fn count(&self) -> usize { + self.0.len() + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + let mut out = Vec::with_capacity(self.0.len() * 8); + for (n, d) in self.0 { + out.extend_from_slice(&n.to_ne_bytes()); + out.extend_from_slice(&d.to_ne_bytes()); + } + std::borrow::Cow::Owned(out) + } +} + /// Tag 34675, `InterColourProfile`. Not in the `tiff` crate's `Tag` enum. const TAG_ICC_PROFILE: u16 = 34675; -fn tiff8(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { +fn tiff8( + rgba: &[u8], + width: u32, + height: u32, + profile: &[u8], + source: Option<&SourceMetadata>, +) -> Result, ExportError> { use tiff::encoder::{colortype, TiffEncoder}; let mut bytes = std::io::Cursor::new(Vec::new()); let mut encoder = TiffEncoder::new(&mut bytes).map_err(|e| ExportError::Encode(e.to_string()))?; + let sub = sub_directories(&mut encoder, source, width, height)?; let mut image = encoder .new_image::(width, height) .map_err(|e| ExportError::Encode(e.to_string()))?; tag_profile(image.encoder(), profile)?; + tag_metadata(image.encoder(), source, &sub)?; image .write_data(&rgb(rgba)) .map_err(|e| ExportError::Encode(e.to_string()))?; @@ -172,10 +303,214 @@ where W: std::io::Write + std::io::Seek, K: tiff::encoder::TiffKind, { - dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), IccTag(profile)) + dir.write_tag(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE), Undefined(profile)) .map_err(|e| ExportError::Encode(e.to_string())) } +/// TRACES: FR-EXP-8 +/// Where the Exif and GPS directories ended up in the file. +/// +/// Byte offsets from the start of the TIFF, which is what the pointer tags in +/// the image directory hold. `None` where that directory was not written at +/// all — the GPS one is `None` for every export that stripped the location, +/// and then no pointer is written either, so the file has no trace of the +/// directory rather than a pointer to an empty one. +#[derive(Default)] +struct SubDirectories { + exif: Option, + gps: Option, +} + +/// TRACES: FR-EXP-8 +/// Write the Exif and GPS sub-directories, ahead of the image. +/// +/// **Ahead of it because a pointer has to point at something.** The image +/// directory carries `ExifDirectory` and `GpsDirectory` as byte offsets, so +/// the directories they name have to exist and have known positions before +/// that entry is written. The `tiff` crate calls these "extra" directories: +/// written into the file but not linked into the chain a reader walks for +/// images, which is exactly what a sub-IFD is. +/// +/// A TIFF gets no separate EXIF *block* — no APP1, no `eXIf` chunk. Its own +/// directory is the EXIF structure, and adding a second copy inside it would +/// give a reader two answers to every question. +fn sub_directories( + encoder: &mut tiff::encoder::TiffEncoder, + source: Option<&SourceMetadata>, + width: u32, + height: u32, +) -> Result +where + W: std::io::Write + std::io::Seek, +{ + use tiff::tags::Tag; + + let Some(md) = source else { + return Ok(SubDirectories::default()); + }; + let mut out = SubDirectories::default(); + + { + let mut dir = encoder + .extra_directory() + .map_err(|e| ExportError::Encode(e.to_string()))?; + let write = |dir: &mut tiff::encoder::DirectoryEncoder<'_, W, _>| -> tiff::TiffResult<()> { + // "0232" is Exif 2.32. A directory without a version is malformed, + // and some readers discard the whole thing over it. + dir.write_tag(Tag::ExifVersion, Undefined(b"0232"))?; + dir.write_tag(Tag::Unknown(exif::tag::PIXEL_X), width)?; + dir.write_tag(Tag::Unknown(exif::tag::PIXEL_Y), height)?; + if let Some(lens) = trimmed(md.lens.as_deref()) { + dir.write_tag(Tag::Unknown(exif::tag::LENS_MODEL), Ascii(lens))?; + } + if let Some(t) = md.captured_at.map(exif::datetime) { + dir.write_tag(Tag::Unknown(exif::tag::DATE_TIME_ORIGINAL), Ascii(&t))?; + } + if let Some(o) = md.captured_offset.map(exif::offset) { + dir.write_tag(Tag::Unknown(exif::tag::OFFSET_TIME_ORIGINAL), Ascii(&o))?; + } + if let Some(s) = md.shutter.filter(|s| *s > 0.0) { + dir.write_tag( + Tag::Unknown(exif::tag::EXPOSURE_TIME), + Rationals(&[exif::shutter(s)]), + )?; + } + if let Some(f) = md.aperture.filter(|f| *f > 0.0) { + dir.write_tag(Tag::Unknown(exif::tag::FNUMBER), Rationals(&[exif::tenths(f)]))?; + } + if let Some(f) = md.focal_length.filter(|f| *f > 0.0) { + dir.write_tag( + Tag::Unknown(exif::tag::FOCAL_LENGTH), + Rationals(&[exif::tenths(f)]), + )?; + } + // A SHORT cannot hold ISO 102400, so it is dropped rather than + // wrapped round to a number that looks plausible and is not. + if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) { + dir.write_tag(Tag::Unknown(exif::tag::ISO), iso as u16)?; + } + Ok(()) + }; + write(&mut dir).map_err(|e| ExportError::Encode(e.to_string()))?; + let offsets = dir + .finish_with_offsets() + .map_err(|e| ExportError::Encode(e.to_string()))?; + // A classic TIFF cannot exceed 4 GB, so the pointer is a `LONG`; the + // crate keeps the offset as a `u64` only because BigTIFF shares the + // type. + out.exif = Some(offsets.pointer.0 as u32); + } + + // TRACES: FR-EXP-8 + // Only reached when a location survived sanitising, which it does only + // when the user turned stripping off. There is no "write an empty GPS + // directory" branch, deliberately. + if let Some(loc) = md.location { + let mut dir = encoder + .extra_directory() + .map_err(|e| ExportError::Encode(e.to_string()))?; + let write = |dir: &mut tiff::encoder::DirectoryEncoder<'_, W, _>| -> tiff::TiffResult<()> { + dir.write_tag(Tag::Unknown(exif::tag::GPS_VERSION_ID), &[2u8, 3, 0, 0][..])?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_LATITUDE_REF), + Ascii(if loc.latitude < 0.0 { "S" } else { "N" }), + )?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_LATITUDE), + Rationals(&exif::dms(loc.latitude)), + )?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_LONGITUDE_REF), + Ascii(if loc.longitude < 0.0 { "W" } else { "E" }), + )?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_LONGITUDE), + Rationals(&exif::dms(loc.longitude)), + )?; + if let Some(alt) = loc.altitude { + dir.write_tag( + Tag::Unknown(exif::tag::GPS_ALTITUDE_REF), + &[u8::from(alt < 0.0)][..], + )?; + dir.write_tag( + Tag::Unknown(exif::tag::GPS_ALTITUDE), + Rationals(&[((alt.abs() * 100.0).round() as u32, 100)]), + )?; + } + Ok(()) + }; + write(&mut dir).map_err(|e| ExportError::Encode(e.to_string()))?; + let offsets = dir + .finish_with_offsets() + .map_err(|e| ExportError::Encode(e.to_string()))?; + out.gps = Some(offsets.pointer.0 as u32); + } + + Ok(out) +} + +/// TRACES: FR-EXP-8 +/// The identity and rights tags, in the image directory itself. +/// +/// These are baseline TIFF tags rather than EXIF private ones — `Make`, +/// `Model`, `Artist`, `Copyright` and `DateTime` have been in the TIFF +/// specification since 1992 — so a reader that knows nothing about EXIF still +/// finds them. The capture tags cannot join them: `ExposureTime` and the rest +/// are only meaningful inside an Exif directory, which is why the pointers +/// exist. +/// +/// No `Orientation`, for the reason `exif.rs` gives at length: the pixels +/// arriving here are already upright. +fn tag_metadata( + dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>, + source: Option<&SourceMetadata>, + sub: &SubDirectories, +) -> Result<(), ExportError> +where + W: std::io::Write + std::io::Seek, + K: tiff::encoder::TiffKind, +{ + use tiff::tags::Tag; + + let Some(md) = source else { + return Ok(()); + }; + let write = |dir: &mut tiff::encoder::DirectoryEncoder<'_, W, K>| -> tiff::TiffResult<()> { + if let Some(v) = trimmed(md.make.as_deref()) { + dir.write_tag(Tag::Make, Ascii(v))?; + } + if let Some(v) = trimmed(md.model.as_deref()) { + dir.write_tag(Tag::Model, Ascii(v))?; + } + if let Some(v) = trimmed(md.artist.as_deref()) { + dir.write_tag(Tag::Artist, Ascii(v))?; + } + if let Some(v) = trimmed(md.copyright.as_deref()) { + dir.write_tag(Tag::Copyright, Ascii(v))?; + } + dir.write_tag(Tag::Software, Ascii(exif::SOFTWARE))?; + if let Some(t) = md.captured_at.map(exif::datetime) { + dir.write_tag(Tag::DateTime, Ascii(&t))?; + } + if let Some(offset) = sub.exif { + dir.write_tag(Tag::ExifDirectory, offset)?; + } + if let Some(offset) = sub.gps { + dir.write_tag(Tag::GpsDirectory, offset)?; + } + Ok(()) + }; + write(dir).map_err(|e| ExportError::Encode(e.to_string())) +} + +/// A string worth writing, or nothing. +/// +/// An empty tag is worse than an absent one: it asserts that the camera had no +/// name, where absence merely says nobody recorded it. +fn trimmed(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|v| !v.is_empty()) +} + /// 16-bit TIFF, for work continuing in another editor. /// /// **Honest about what it carries.** The adjust pass renders to an 8-bit @@ -190,7 +525,13 @@ where /// one: the composer has to be told what format to write, and export has to /// ask for the wide one (FR-EXP-9). Until then this is a container promotion, /// which is still the right thing to hand an editor that works in 16-bit. -fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result, ExportError> { +fn tiff16( + rgba: &[u8], + width: u32, + height: u32, + profile: &[u8], + source: Option<&SourceMetadata>, +) -> Result, ExportError> { use tiff::encoder::{colortype, TiffEncoder}; // `x * 257` rather than `x << 8`: it maps 255 to 65535 exactly, where the @@ -200,10 +541,12 @@ fn tiff16(rgba: &[u8], width: u32, height: u32, profile: &[u8]) -> Result(width, height) .map_err(|e| ExportError::Encode(e.to_string()))?; tag_profile(image.encoder(), profile)?; + tag_metadata(image.encoder(), source, &sub)?; image .write_data(&wide) .map_err(|e| ExportError::Encode(e.to_string()))?; @@ -242,7 +585,7 @@ mod tests { 0, 0, 255, 255, // blue 10, 20, 30, 255, ]; - let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap(); + let bytes = png(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb), None).unwrap(); let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); let mut reader = decoder.read_info().unwrap(); @@ -268,7 +611,7 @@ mod tests { // discards silently, leaving the file to be guessed at as sRGB. for space in ColourSpace::ALL { let want = icc::profile(space); - let bytes = png(&flat(4, 4), 4, 4, &want).unwrap(); + let bytes = png(&flat(4, 4), 4, 4, &want, None).unwrap(); let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); let reader = decoder.read_info().unwrap(); @@ -289,7 +632,7 @@ mod tests { // walking it here is the only way to know the file is really tagged. for space in ColourSpace::ALL { let want = icc::profile(space); - let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want).unwrap(); + let bytes = jpeg(&flat(4, 4), 4, 4, 90, &want, None).unwrap(); let got = jpeg_icc(&bytes) .unwrap_or_else(|| panic!("{space:?} JPEG has no ICC_PROFILE segment")); assert_eq!(got, want, "{space:?}"); @@ -336,8 +679,8 @@ mod tests { for space in ColourSpace::ALL { let want = icc::profile(space); for (label, bytes) in [ - ("8-bit", tiff8(&flat(4, 4), 4, 4, &want).unwrap()), - ("16-bit", tiff16(&flat(4, 4), 4, 4, &want).unwrap()), + ("8-bit", tiff8(&flat(4, 4), 4, 4, &want, None).unwrap()), + ("16-bit", tiff16(&flat(4, 4), 4, 4, &want, None).unwrap()), ] { let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); let got = d @@ -361,7 +704,7 @@ mod tests { 0, 0, 255, 255, // blue 10, 20, 30, 255, ]; - let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb)).unwrap(); + let bytes = tiff8(&rgba, 2, 2, &icc::profile(ColourSpace::Srgb), None).unwrap(); let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); assert_eq!(d.dimensions().expect("dimensions"), (2, 2)); let DecodingResult::U8(pixels) = d.read_image().expect("read") else { @@ -372,4 +715,296 @@ mod tests { &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30] ); } + + // ----------------------------------------------------------------------- + // Metadata (FR-EXP-8) + // + // Read back through `dr-decode`, the same reader the application uses on + // the way in. That is the point: a test with its own parser proves the two + // agree with each other and nothing else, whereas this proves an exported + // file re-imports as the photograph it came from — and, in the stripping + // direction, that the coordinates are not there to be found by the very + // code most likely to find them. + // ----------------------------------------------------------------------- + + /// A source with every field filled, including a position. + fn source() -> SourceMetadata { + SourceMetadata { + make: Some("Canon".into()), + model: Some("Canon EOS 6D".into()), + lens: Some("EF85mm f/1.8 USM".into()), + shutter: Some(1.0 / 250.0), + aperture: Some(2.8), + iso: Some(400), + focal_length: Some(85.0), + captured_at: Some(1_372_462_374), + captured_offset: Some(120), + artist: Some("Duncan Tourolle".into()), + copyright: Some("(c) 2026 Duncan Tourolle".into()), + // 48° 51' 29.52" N, 2° 17' 40.2" E. + location: dr_types::Location::new(48.8582, 2.2945, Some(35.0)), + } + } + + /// Encode one small frame of every format under `settings`. + fn exported(settings: &ExportSettings, md: &SourceMetadata) -> Vec<(ExportFormat, Vec)> { + [ + ExportFormat::Jpeg, + ExportFormat::Png, + ExportFormat::Tiff8, + ExportFormat::Tiff16, + ] + .into_iter() + .map(|format| { + let s = ExportSettings { + format, + ..settings.clone() + }; + let bytes = encode(&flat(8, 8), 8, 8, &s, Some(md)) + .unwrap_or_else(|e| panic!("{format:?}: {e}")); + (format, bytes) + }) + .collect() + } + + /// The EXIF an exported file carries, as `dr-decode` reads it. + /// + /// Each container hides the same TIFF structure somewhere different, so + /// finding it is per-format; what happens to it afterwards is not. + fn read_back(format: ExportFormat, bytes: &[u8]) -> Option { + match format { + ExportFormat::Jpeg => dr_decode::jpeg_metadata(bytes).ok(), + ExportFormat::Png => { + let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); + let reader = decoder.read_info().expect("a readable PNG"); + let chunk = reader.info().exif_metadata.clone()?; + dr_decode::tiff_metadata(&chunk).ok() + } + // A TIFF's own directory is the EXIF, so the file is the block. + _ => dr_decode::tiff_metadata(bytes).ok(), + } + } + + /// Whether the bytes contain a directory entry pointing at a GPS + /// directory. + /// + /// TRACES: FR-EXP-8 + /// Deliberately byte-level, and deliberately not "did the parser find a + /// position". A GPS directory is only reachable through tag 0x8825 with + /// field type `LONG`, so those four bytes are the whole of the evidence: + /// if they are nowhere in the file then no reader — ours, exiftool, a + /// social network's ingest pipeline — has a route to a coordinate, + /// whatever else the file contains. Both byte orders are checked because + /// the `tiff` crate writes in the host's. + fn has_gps_pointer(bytes: &[u8]) -> bool { + const LITTLE: [u8; 4] = [0x25, 0x88, 0x04, 0x00]; + const BIG: [u8; 4] = [0x88, 0x25, 0x00, 0x04]; + bytes.windows(4).any(|w| w == LITTLE || w == BIG) + } + + #[test] + fn no_export_carries_a_location_by_default() { + // TRACES: FR-EXP-8 + // The important one. The source has a fix, the defaults are what a + // photographer who has changed nothing gets, and the assertion is + // about the *bytes* rather than about a flag having been read. + let settings = ExportSettings::default(); + assert!(settings.strip_location, "the default this test rests on"); + + for (format, bytes) in exported(&settings, &source()) { + assert!( + !has_gps_pointer(&bytes), + "{format:?} carries a GPS directory pointer" + ); + let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); + assert_eq!(md.location, None, "{format:?} decodes to a position"); + // And the coordinates are not loose in the file under some other + // tag: the hemisphere letters a GPS directory always carries. + assert!( + !bytes.windows(2).any(|w| w == b"N\0" || w == b"E\0"), + "{format:?} contains a hemisphere reference" + ); + } + } + + #[test] + fn stripping_the_location_keeps_everything_else() { + // The other half of the same export: a photographer loses their + // coordinates, not their byline. A strip that took the copyright with + // it would pass the test above and still be wrong. + for (format, bytes) in exported(&ExportSettings::default(), &source()) { + let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); + assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}"); + assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}"); + assert_eq!( + md.copyright.as_deref(), + Some("(c) 2026 Duncan Tourolle"), + "{format:?}" + ); + assert_eq!(md.captured_at, Some(1_372_462_374), "{format:?}"); + } + } + + #[test] + fn the_camera_and_the_copyright_survive_the_round_trip() { + // TRACES: FR-EXP-8 + // The retaining direction, with stripping off so that the position + // travels too — which is also the control for the test above: it + // proves that a missing GPS directory there is the setting working + // rather than the writer being incapable of one. + let settings = ExportSettings { + retain_metadata: true, + strip_location: false, + ..Default::default() + }; + + for (format, bytes) in exported(&settings, &source()) { + assert!( + has_gps_pointer(&bytes), + "{format:?} dropped the position it was asked to keep" + ); + let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); + assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}"); + assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}"); + assert_eq!(md.lens.as_deref(), Some("EF85mm f/1.8 USM"), "{format:?}"); + assert_eq!(md.artist.as_deref(), Some("Duncan Tourolle"), "{format:?}"); + assert_eq!( + md.copyright.as_deref(), + Some("(c) 2026 Duncan Tourolle"), + "{format:?}" + ); + assert_eq!(md.captured_at, Some(1_372_462_374), "{format:?}"); + assert_eq!(md.captured_offset, Some(120), "{format:?}"); + assert_eq!(md.iso, Some(400), "{format:?}"); + assert_eq!(md.shutter, Some(1.0 / 250.0), "{format:?}"); + assert_eq!(md.aperture, Some(2.8), "{format:?}"); + assert_eq!(md.focal_length, Some(85.0), "{format:?}"); + + let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix")); + // Within a metre of where it started, which is finer than any + // consumer receiver and far finer than the tag's own rounding. + assert!((loc.latitude - 48.8582).abs() < 1e-5, "{format:?} {loc:?}"); + assert!((loc.longitude - 2.2945).abs() < 1e-5, "{format:?} {loc:?}"); + assert_eq!(loc.altitude, Some(35.0), "{format:?}"); + } + } + + #[test] + fn retention_off_writes_no_metadata_at_all() { + // Not an emptied block — none. This is the setting for a file that + // must give nothing away, and a reader should find the same absence a + // file that never had EXIF has. + let settings = ExportSettings { + retain_metadata: false, + strip_location: false, + ..Default::default() + }; + + for (format, bytes) in exported(&settings, &source()) { + assert!(!has_gps_pointer(&bytes), "{format:?}"); + match format { + // No APP1 segment at all, which is what the error means here. + ExportFormat::Jpeg => assert!(dr_decode::jpeg_metadata(&bytes).is_err()), + ExportFormat::Png => { + let decoder = png::Decoder::new(std::io::Cursor::new(&bytes)); + let reader = decoder.read_info().expect("a readable PNG"); + assert!(reader.info().exif_metadata.is_none()); + } + _ => { + let md = read_back(format, &bytes).expect("a TIFF is always a directory"); + assert_eq!(md.make, None, "{format:?}"); + assert_eq!(md.copyright, None, "{format:?}"); + assert_eq!(md.captured_at, None, "{format:?}"); + } + } + } + } + + #[test] + fn an_export_is_not_told_to_rotate_pixels_that_are_already_upright() { + // The pipeline applies the source's orientation before this point, so + // an orientation tag here would turn every portrait frame on its side + // in every viewer that honours one. `dr-decode` reporting no + // orientation is the assertion: it reads the tag from the main IFD, + // which is exactly where a careless copy would have put it. + let settings = ExportSettings { + retain_metadata: true, + ..Default::default() + }; + for (format, bytes) in exported(&settings, &source()) { + let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); + assert_eq!(md.orientation, None, "{format:?} tells a reader to rotate"); + } + } + + #[test] + fn a_tiff_carrying_metadata_still_decodes_to_its_pixels() { + // Sub-directories are written into the file *before* the image, so a + // mistake here moves the strip offsets — the failure that produces a + // file which opens, reports the right size, and shows noise. + use tiff::decoder::{Decoder, DecodingResult}; + + let rgba: Vec = vec![ + 255, 0, 0, 255, // red + 0, 255, 0, 255, // green + 0, 0, 255, 255, // blue + 10, 20, 30, 255, + ]; + let bytes = tiff8( + &rgba, + 2, + 2, + &icc::profile(ColourSpace::Srgb), + Some(&source()), + ) + .unwrap(); + + let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); + assert_eq!(d.dimensions().expect("dimensions"), (2, 2)); + let DecodingResult::U8(pixels) = d.read_image().expect("read") else { + panic!("expected 8-bit samples"); + }; + assert_eq!( + &pixels[..12], + &[255, 0, 0, 0, 255, 0, 0, 0, 255, 10, 20, 30] + ); + // And the profile is still where it was, beside the new tags. + let mut d = Decoder::new(std::io::Cursor::new(&bytes)).expect("decode"); + assert_eq!( + d.get_tag_u8_vec(tiff::tags::Tag::Unknown(TAG_ICC_PROFILE)) + .expect("profile"), + icc::profile(ColourSpace::Srgb) + ); + } + + #[test] + fn a_jpeg_keeps_both_its_profile_and_its_capture_data() { + // Two APP segments now, and adding one must not have displaced the + // other: a reader walking the marker chain has to find both. + let settings = ExportSettings { + retain_metadata: true, + ..Default::default() + }; + let bytes = encode(&flat(8, 8), 8, 8, &settings, Some(&source())).unwrap(); + let profile = jpeg_icc(&bytes).expect("the ICC segment"); + assert_eq!(profile, icc::profile(ColourSpace::Srgb)); + let md = dr_decode::jpeg_metadata(&bytes).expect("the EXIF segment"); + assert_eq!(md.model.as_deref(), Some("Canon EOS 6D")); + } + + #[test] + fn a_frame_with_no_source_metadata_exports_exactly_as_it_used_to() { + // The `None` path is the one every caller that has not been taught + // about metadata still takes, and it must not have acquired a block. + let settings = ExportSettings::default(); + for format in [ExportFormat::Jpeg, ExportFormat::Png] { + let s = ExportSettings { + format, + ..settings.clone() + }; + let bytes = encode(&flat(8, 8), 8, 8, &s, None).unwrap(); + assert!(!has_gps_pointer(&bytes), "{format:?}"); + assert!(read_back(format, &bytes).is_none(), "{format:?}"); + } + } } diff --git a/core/dr-export/src/exif.rs b/core/dr-export/src/exif.rs new file mode 100644 index 0000000..239848c --- /dev/null +++ b/core/dr-export/src/exif.rs @@ -0,0 +1,518 @@ +//! TRACES: FR-EXP-8 +//! Building an EXIF block, rather than copying one. +//! +//! # Why this is written by hand and not with a crate +//! +//! Two reasons, in order of importance. +//! +//! The first is the privacy behaviour. Every EXIF library worth using offers a +//! "load the source block, delete these tags, write it back" shape, and that +//! shape is the wrong one here: it makes the file that leaves the machine a +//! copy of the source's metadata *minus what we thought to remove*, so every +//! tag nobody has thought about — a vendor's proprietary sub-directory, a +//! serial number under a tag id this build has never seen — travels by +//! default. Constructing the block from a fixed list of parsed values inverts +//! that. What is written is exactly what appears in [`crate::SourceMetadata`], +//! and a tag that is not in this file cannot end up in the output no matter +//! what the source contained. The allowlist *is* the implementation. +//! +//! The second is the dependency policy. The root `Cargo.toml` explains why +//! nothing here may link C — this tree has to build under the Android NDK — +//! and the mature EXIF writers are bindings. This is a couple of hundred +//! lines of offset arithmetic against a specification that has not changed +//! since 2010, and it is the same TIFF structure `dr-decode` already reads. +//! +//! # What the block is +//! +//! A complete little-endian TIFF: an 8-byte header, IFD0 with the identity +//! and rights tags, an Exif sub-IFD with the capture tags, optionally a GPS +//! sub-IFD, and a heap of values too long to sit inside an entry. JPEG carries +//! it in an APP1 segment behind the marker `Exif\0\0`; PNG carries the same +//! bytes in an `eXIf` chunk with no marker. TIFF does not use this at all — +//! its own directory *is* the EXIF, so `encode.rs` writes the tags there +//! directly. + +use crate::metadata::SourceMetadata; + +/// One entry's value, in the handful of TIFF types this writer emits. +enum Value { + /// NUL-terminated, as the specification requires; the terminator is + /// counted, which is the detail readers trip over when it is missing. + Ascii(String), + Byte(Vec), + Short(u16), + Long(u32), + /// Type 7. Used only for `ExifVersion`, which is four characters that are + /// deliberately *not* a string. + Undefined(&'static [u8]), + /// Numerator and denominator pairs. A coordinate is three of them. + Rational(Vec<(u32, u32)>), +} + +impl Value { + fn field_type(&self) -> u16 { + match self { + Value::Byte(_) => 1, + Value::Ascii(_) => 2, + Value::Short(_) => 3, + Value::Long(_) => 4, + Value::Rational(_) => 5, + Value::Undefined(_) => 7, + } + } + + /// The element count, which is not the byte length: a rational counts as + /// one element per eight bytes. + fn count(&self) -> u32 { + match self { + Value::Ascii(s) => s.len() as u32 + 1, + Value::Byte(b) => b.len() as u32, + Value::Undefined(b) => b.len() as u32, + Value::Short(_) | Value::Long(_) => 1, + Value::Rational(r) => r.len() as u32, + } + } + + /// The payload, in file order. + fn payload(&self) -> Vec { + match self { + Value::Ascii(s) => { + let mut out = s.as_bytes().to_vec(); + out.push(0); + out + } + Value::Byte(b) => b.clone(), + Value::Undefined(b) => b.to_vec(), + Value::Short(v) => v.to_le_bytes().to_vec(), + Value::Long(v) => v.to_le_bytes().to_vec(), + Value::Rational(r) => r + .iter() + .flat_map(|(n, d)| { + let mut b = n.to_le_bytes().to_vec(); + b.extend_from_slice(&d.to_le_bytes()); + b + }) + .collect(), + } + } +} + +/// An IFD under construction. +type Entries = Vec<(u16, Value)>; + +/// Tag numbers. Named rather than inlined because a mistyped one produces a +/// file that still parses and says something else entirely. +pub(crate) mod tag { + pub(crate) const MAKE: u16 = 0x010F; + pub(crate) const MODEL: u16 = 0x0110; + pub(crate) const SOFTWARE: u16 = 0x0131; + pub(crate) const DATE_TIME: u16 = 0x0132; + pub(crate) const ARTIST: u16 = 0x013B; + pub(crate) const COPYRIGHT: u16 = 0x8298; + pub(crate) const EXIF_IFD: u16 = 0x8769; + pub(crate) const GPS_IFD: u16 = 0x8825; + + pub(crate) const EXPOSURE_TIME: u16 = 0x829A; + pub(crate) const FNUMBER: u16 = 0x829D; + pub(crate) const ISO: u16 = 0x8827; + pub(crate) const EXIF_VERSION: u16 = 0x9000; + pub(crate) const DATE_TIME_ORIGINAL: u16 = 0x9003; + pub(crate) const OFFSET_TIME_ORIGINAL: u16 = 0x9011; + pub(crate) const FOCAL_LENGTH: u16 = 0x920A; + pub(crate) const PIXEL_X: u16 = 0xA002; + pub(crate) const PIXEL_Y: u16 = 0xA003; + pub(crate) const LENS_MODEL: u16 = 0xA434; + + pub(crate) const GPS_VERSION_ID: u16 = 0x0000; + pub(crate) const GPS_LATITUDE_REF: u16 = 0x0001; + pub(crate) const GPS_LATITUDE: u16 = 0x0002; + pub(crate) const GPS_LONGITUDE_REF: u16 = 0x0003; + pub(crate) const GPS_LONGITUDE: u16 = 0x0004; + pub(crate) const GPS_ALTITUDE_REF: u16 = 0x0005; + pub(crate) const GPS_ALTITUDE: u16 = 0x0006; +} + +/// What DarkRoom calls itself in a file it wrote. +/// +/// Not vanity: an export is a derived file, and a reader that knows which +/// program produced it can tell a camera original from a rendition without +/// guessing from the absence of a maker note. +pub(crate) const SOFTWARE: &str = "DarkRoom"; + +/// The complete EXIF block for JPEG's APP1 and PNG's `eXIf`. +/// +/// `width`/`height` are the *exported* dimensions, not the source's: the +/// pixel-dimension tags describe the file they are in, and a reader that +/// trusts them after a resize would report the wrong size for the image it is +/// holding. +/// +/// `None` where there is nothing to say. An empty EXIF block is not the same +/// as no EXIF block — it is a structure a reader must parse to discover it +/// learned nothing — and the second is the better file. +pub(crate) fn block(md: &SourceMetadata, width: u32, height: u32) -> Option> { + let ifd0 = main_entries(md); + let exif = exif_entries(md, width, height); + let gps = gps_entries(md); + if ifd0.is_empty() && exif.is_empty() && gps.is_empty() { + return None; + } + Some(assemble(ifd0, exif, gps)) +} + +/// Lay the three directories and their heap out in the block. +/// +/// The order is fixed — IFD0, Exif, GPS, heap — because the pointers have to +/// be known before IFD0 is serialised, and an IFD's size is decided by its +/// entry count alone: two bytes of count, twelve per entry, four for the link +/// to the next directory. +fn assemble(mut ifd0: Entries, exif: Entries, gps: Entries) -> Vec { + const HEADER: u32 = 8; + let size = |n: usize| 2 + 12 * n as u32 + 4; + + // The pointer entries are part of IFD0's count, so they have to be added + // before its size is taken — a chicken-and-egg the specification resolves + // by making entry size fixed. + let pointers = usize::from(!exif.is_empty()) + usize::from(!gps.is_empty()); + let ifd0_size = size(ifd0.len() + pointers); + + let exif_offset = HEADER + ifd0_size; + let gps_offset = exif_offset + if exif.is_empty() { 0 } else { size(exif.len()) }; + let heap_base = gps_offset + if gps.is_empty() { 0 } else { size(gps.len()) }; + + if !exif.is_empty() { + ifd0.push((tag::EXIF_IFD, Value::Long(exif_offset))); + } + if !gps.is_empty() { + ifd0.push((tag::GPS_IFD, Value::Long(gps_offset))); + } + + let mut heap = Vec::new(); + let ifd0_bytes = directory(ifd0, heap_base, &mut heap); + let exif_bytes = directory(exif, heap_base, &mut heap); + let gps_bytes = directory(gps, heap_base, &mut heap); + + let mut out = Vec::with_capacity(HEADER as usize + heap.len() + 128); + // Little-endian, magic 42, first directory at byte 8. Little-endian + // because every value written below is, and a header that disagreed with + // its own body is the one corruption a reader cannot recover from. + out.extend_from_slice(b"II"); + out.extend_from_slice(&42u16.to_le_bytes()); + out.extend_from_slice(&HEADER.to_le_bytes()); + out.extend_from_slice(&ifd0_bytes); + out.extend_from_slice(&exif_bytes); + out.extend_from_slice(&gps_bytes); + out.extend_from_slice(&heap); + out +} + +/// Serialise one directory, spilling long values onto the shared heap. +/// +/// Entries are sorted by tag: TIFF requires ascending order within a +/// directory, and while most readers cope with any order, the ones that +/// binary-search stop at the first tag they cannot place. +fn directory(mut entries: Entries, heap_base: u32, heap: &mut Vec) -> Vec { + if entries.is_empty() { + return Vec::new(); + } + entries.sort_by_key(|(tag, _)| *tag); + + let mut out = Vec::with_capacity(2 + entries.len() * 12 + 4); + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for (tag, value) in &entries { + out.extend_from_slice(&tag.to_le_bytes()); + out.extend_from_slice(&value.field_type().to_le_bytes()); + out.extend_from_slice(&value.count().to_le_bytes()); + + let payload = value.payload(); + if payload.len() <= 4 { + // Four bytes or fewer live in the entry itself, left-justified and + // zero-padded. + let mut inline = payload.clone(); + inline.resize(4, 0); + out.extend_from_slice(&inline); + } else { + out.extend_from_slice(&(heap_base + heap.len() as u32).to_le_bytes()); + heap.extend_from_slice(&payload); + // Values start on even offsets. Not every reader cares; the ones + // that do read a short from an odd address and get nonsense. + if heap.len() % 2 == 1 { + heap.push(0); + } + } + } + // No directory follows this one. The Exif and GPS sub-directories are + // pointed at, not chained, so this is zero in all three. + out.extend_from_slice(&0u32.to_le_bytes()); + out +} + +/// IFD0: who took it, with what, and who owns it. +/// +/// **No orientation tag, deliberately.** The frame reaching the encoder has +/// already had the source's orientation applied by the pipeline — it is +/// upright pixels — so copying the source's tag across would tell every +/// reader to rotate an image that is already the right way up. A portrait +/// frame would come out on its side in exactly the viewers that honour the +/// tag, which is most of them. +fn main_entries(md: &SourceMetadata) -> Entries { + let mut e = Entries::new(); + push_ascii(&mut e, tag::MAKE, md.make.as_deref()); + push_ascii(&mut e, tag::MODEL, md.model.as_deref()); + push_ascii(&mut e, tag::ARTIST, md.artist.as_deref()); + push_ascii(&mut e, tag::COPYRIGHT, md.copyright.as_deref()); + e.push((tag::SOFTWARE, Value::Ascii(SOFTWARE.to_string()))); + // IFD0's `DateTime` is nominally when the file was written, and this is + // the capture time instead. That is what the rest of the world does — + // and it is what `dr-decode` falls back to for scanner output that has no + // `DateTimeOriginal` — so a re-import of an export lands on the timeline + // where the original did rather than on the day it was exported. + if let Some(t) = md.captured_at.map(datetime) { + e.push((tag::DATE_TIME, Value::Ascii(t))); + } + e +} + +/// The Exif sub-IFD: the exposure, and what made it. +fn exif_entries(md: &SourceMetadata, width: u32, height: u32) -> Entries { + let mut e = Entries::new(); + // "0232" is Exif 2.32. A sub-directory without a version is technically + // malformed, and some readers refuse the whole block over it. + e.push((tag::EXIF_VERSION, Value::Undefined(b"0232"))); + e.push((tag::PIXEL_X, Value::Long(width))); + e.push((tag::PIXEL_Y, Value::Long(height))); + push_ascii(&mut e, tag::LENS_MODEL, md.lens.as_deref()); + if let Some(t) = md.captured_at.map(datetime) { + e.push((tag::DATE_TIME_ORIGINAL, Value::Ascii(t))); + } + if let Some(o) = md.captured_offset.map(offset) { + e.push((tag::OFFSET_TIME_ORIGINAL, Value::Ascii(o))); + } + if let Some(s) = md.shutter.filter(|s| *s > 0.0) { + e.push((tag::EXPOSURE_TIME, Value::Rational(vec![shutter(s)]))); + } + if let Some(f) = md.aperture.filter(|f| *f > 0.0) { + e.push((tag::FNUMBER, Value::Rational(vec![tenths(f)]))); + } + if let Some(f) = md.focal_length.filter(|f| *f > 0.0) { + e.push((tag::FOCAL_LENGTH, Value::Rational(vec![tenths(f)]))); + } + // The tag is a SHORT, so a sensitivity above 65535 has no representation + // in it. Dropped rather than truncated: ISO 102400 written as 36864 is a + // lie, and an absent tag is not. + if let Some(iso) = md.iso.filter(|v| *v <= u32::from(u16::MAX)) { + e.push((tag::ISO, Value::Short(iso as u16))); + } + e +} + +/// The GPS sub-IFD. +/// +/// Empty unless the caller has already decided that coordinates may be +/// written — see [`SourceMetadata::sanitised`], which is where the stripping +/// happens. Nothing in this file consults the settings, so there is exactly +/// one place to look to answer "can this export carry a location". +fn gps_entries(md: &SourceMetadata) -> Entries { + let Some(loc) = md.location else { + return Entries::new(); + }; + let mut e = Entries::new(); + // 2.3.0.0, the current GPS tag version. + e.push((tag::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0]))); + e.push(( + tag::GPS_LATITUDE_REF, + Value::Ascii(if loc.latitude < 0.0 { "S" } else { "N" }.into()), + )); + e.push((tag::GPS_LATITUDE, Value::Rational(dms(loc.latitude)))); + e.push(( + tag::GPS_LONGITUDE_REF, + Value::Ascii(if loc.longitude < 0.0 { "W" } else { "E" }.into()), + )); + e.push((tag::GPS_LONGITUDE, Value::Rational(dms(loc.longitude)))); + if let Some(alt) = loc.altitude { + // The altitude itself is unsigned; below sea level is a separate byte. + e.push(( + tag::GPS_ALTITUDE_REF, + Value::Byte(vec![u8::from(alt < 0.0)]), + )); + e.push(( + tag::GPS_ALTITUDE, + Value::Rational(vec![((alt.abs() * 100.0).round() as u32, 100)]), + )); + } + e +} + +fn push_ascii(entries: &mut Entries, tag: u16, value: Option<&str>) { + // An empty string is a tag saying nothing, which is worse than no tag: it + // overwrites whatever a reader would otherwise have inferred. + if let Some(v) = value.map(str::trim).filter(|v| !v.is_empty()) { + entries.push((tag, Value::Ascii(v.to_string()))); + } +} + +/// Signed degrees back into the tag's degrees/minutes/seconds. +/// +/// The sign is carried by the hemisphere letter, so this takes the magnitude. +/// Seconds keep four decimal places, which is about 3 mm — far finer than any +/// consumer fix, and enough that a round trip through the tag does not move +/// the pin. +pub(crate) fn dms(degrees: f64) -> Vec<(u32, u32)> { + let d = degrees.abs(); + let whole = d.trunc(); + let minutes = (d - whole) * 60.0; + let seconds = (minutes - minutes.trunc()) * 60.0; + vec![ + (whole as u32, 1), + (minutes.trunc() as u32, 1), + ((seconds * 10_000.0).round() as u32, 10_000), + ] +} + +/// A shutter speed as the fraction a photographer would recognise. +/// +/// `1/250`, not `4/1000`. Both are the same number and every reader computes +/// the same exposure from either, but the first is what the camera wrote and +/// what a properties panel displays verbatim. +pub(crate) fn shutter(seconds: f32) -> (u32, u32) { + if seconds < 1.0 { + (1, (1.0 / seconds).round().max(1.0) as u32) + } else { + ((seconds * 10.0).round() as u32, 10) + } +} + +/// f/2.8 and 85 mm as tenths, which is how cameras write both. +pub(crate) fn tenths(value: f32) -> (u32, u32) { + ((value * 10.0).round().max(0.0) as u32, 10) +} + +/// Unix seconds as EXIF's `"YYYY:MM:DD HH:MM:SS"`. +/// +/// The reading is a wall clock with no zone — that is what the tag means, and +/// what `dr-decode` parsed it as — so this is the exact inverse of that parse +/// and involves no timezone conversion. The zone, where the source recorded +/// one, travels separately in `OffsetTimeOriginal`. +pub(crate) fn datetime(unix: i64) -> String { + let days = unix.div_euclid(86_400); + let secs = unix.rem_euclid(86_400); + + // Howard Hinnant's civil-from-days, the inverse of the days-from-civil + // that `dr-decode` uses to parse. Eras of 400 years, shifted so that the + // arithmetic never sees a negative. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!( + "{y:04}:{m:02}:{d:02} {:02}:{:02}:{:02}", + secs / 3600, + (secs / 60) % 60, + secs % 60 + ) +} + +/// Minutes east of UTC as EXIF's `"+HH:MM"`. +pub(crate) fn offset(minutes: i32) -> String { + let sign = if minutes < 0 { '-' } else { '+' }; + let m = minutes.unsigned_abs(); + format!("{sign}{:02}:{:02}", m / 60, m % 60) +} + +#[cfg(test)] +mod tests { + use super::*; + use dr_types::Location; + + #[test] + fn a_capture_time_survives_the_round_trip_through_the_tag() { + // The parse side lives in `dr-decode` and is exercised against real + // files; this is the inverse, and the two meeting in the middle is + // what keeps an exported frame on the same point of the timeline as + // the original. + assert_eq!(datetime(1_372_462_374), "2013:06:28 23:32:54"); + assert_eq!(datetime(0), "1970:01:01 00:00:00"); + // A leap day, which is where a hand-rolled calendar goes wrong. + assert_eq!(datetime(1_709_164_800), "2024:02:29 00:00:00"); + } + + #[test] + fn a_zone_is_written_the_way_the_tag_spells_it() { + assert_eq!(offset(120), "+02:00"); + assert_eq!(offset(-330), "-05:30"); + assert_eq!(offset(0), "+00:00"); + } + + #[test] + fn a_shutter_speed_keeps_the_photographers_fraction() { + assert_eq!(shutter(1.0 / 250.0), (1, 250)); + assert_eq!(shutter(2.5), (25, 10)); + } + + #[test] + fn degrees_round_trip_through_the_tags_triple() { + // 48.8582 N is the Eiffel Tower; the check is that the three-part + // form comes back to the same place, to well under a metre. + for degrees in [48.8582_f64, -33.8568, 0.0, 179.999] { + let parts = dms(degrees); + let back = parts[0].0 as f64 + + parts[1].0 as f64 / 60.0 + + (parts[2].0 as f64 / parts[2].1 as f64) / 3600.0; + assert!( + (back - degrees.abs()).abs() < 1e-6, + "{degrees} came back as {back}" + ); + } + } + + #[test] + fn an_empty_source_produces_no_block_at_all() { + // Every field absent means the only entries would be the ones this + // writer adds itself. That is still worth writing — `Software` and + // the pixel dimensions are true statements — so the block exists; what + // must not happen is a *malformed* one. + let md = SourceMetadata::default(); + let bytes = block(&md, 100, 50).expect("the writer's own tags"); + assert!(bytes.starts_with(b"II*\0")); + } + + #[test] + fn the_gps_directory_is_absent_when_there_is_no_position() { + let md = SourceMetadata { + make: Some("Canon".into()), + ..Default::default() + }; + let bytes = block(&md, 10, 10).unwrap(); + assert!(!contains_entry(&bytes, tag::GPS_IFD)); + } + + #[test] + fn the_gps_directory_is_present_when_there_is_one() { + // The counterpart of the test above: a strip test that passed because + // the writer could never emit GPS at all would prove nothing. + let md = SourceMetadata { + location: Location::new(48.8582, 2.2945, Some(35.0)), + ..Default::default() + }; + let bytes = block(&md, 10, 10).unwrap(); + assert!(contains_entry(&bytes, tag::GPS_IFD)); + } + + /// Whether a directory entry for `tag` appears anywhere in the block. + /// + /// Byte-level on purpose: an entry is a tag, a type and a count, and + /// searching for that twelve-byte shape's first eight bytes is a far + /// stronger statement than asking a parser that might have skipped the + /// directory the tag was in. + fn contains_entry(bytes: &[u8], tag: u16) -> bool { + bytes + .windows(4) + .any(|w| w[..2] == tag.to_le_bytes() && (w[2] == 4 || w[2] == 13) && w[3] == 0) + } +} diff --git a/core/dr-export/src/lib.rs b/core/dr-export/src/lib.rs index 3f9b8b2..4642064 100644 --- a/core/dr-export/src/lib.rs +++ b/core/dr-export/src/lib.rs @@ -26,12 +26,15 @@ use dr_types::{ColourSpace, ExportFormat, ExportSettings}; mod encode; mod error; +mod exif; pub mod icc; +mod metadata; mod name; mod sharpen; mod size; pub use error::ExportError; +pub use metadata::SourceMetadata; pub use name::{resolve_name, NameContext}; pub use size::target_size; @@ -129,10 +132,23 @@ pub struct Encoded { /// resamples down from it. Exporting from the display proxy would silently /// produce a soft file, which is why the develop session's export path renders /// its own frame rather than reusing the one on screen. +/// +/// TRACES: FR-EXP-8 +/// `source` is what the photograph's own file said about itself, or `None` +/// where the caller has nothing — a frame that came from somewhere other than +/// a decoded file, or a caller that has not yet been taught to pass it. +/// +/// **A parameter rather than a field on [`Frame`]**, because it is not a fact +/// about the pixels: two exports of the same frame can legitimately disclose +/// different amounts, and the settings that decide how much travel beside it. +/// It is also why this is an argument and not an `Option` with a default — a +/// caller that has the source metadata should have to decide, in one visible +/// place, to hand it over. pub fn export( frame: &Frame, settings: &ExportSettings, name: String, + source: Option<&SourceMetadata>, ) -> Result { // TRACES: FR-EXP-2 // Refused rather than mislabelled. Every space the settings page offers @@ -170,7 +186,7 @@ pub fn export( let scale = width as f32 / frame.width.max(1) as f32; let sharpened = sharpen::apply(resized, width, height, settings.sharpening, scale); - let bytes = encode::encode(&sharpened, width, height, settings)?; + let bytes = encode::encode(&sharpened, width, height, settings, source)?; Ok(Encoded { name, @@ -223,6 +239,7 @@ mod tests { &frame(64, 48), &settings(ExportFormat::Jpeg), "a.jpg".into(), + None, ) .unwrap(); // SOI marker. Cheap, and it catches an encoder wired to the wrong @@ -233,14 +250,14 @@ mod tests { #[test] fn png_export_produces_a_png() { - let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into()).unwrap(); + let out = export(&frame(32, 32), &settings(ExportFormat::Png), "a.png".into(), None).unwrap(); assert_eq!(&out.bytes[..8], b"\x89PNG\r\n\x1a\n"); } #[test] fn tiff_exports_produce_a_tiff() { for format in [ExportFormat::Tiff8, ExportFormat::Tiff16] { - let out = export(&frame(16, 16), &settings(format), "a.tif".into()).unwrap(); + let out = export(&frame(16, 16), &settings(format), "a.tif".into(), None).unwrap(); // Either byte order is a valid TIFF; the crate writes little-endian. assert!( out.bytes.starts_with(b"II*\0") || out.bytes.starts_with(b"MM\0*"), @@ -253,8 +270,8 @@ mod tests { fn a_sixteen_bit_tiff_is_larger_than_an_eight_bit_one() { // Both are uncompressed RGB; the only difference is the sample width, // so this is what proves the 16-bit path is not quietly writing 8. - let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into()).unwrap(); - let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into()).unwrap(); + let eight = export(&frame(16, 16), &settings(ExportFormat::Tiff8), "a".into(), None).unwrap(); + let sixteen = export(&frame(16, 16), &settings(ExportFormat::Tiff16), "a".into(), None).unwrap(); assert!(sixteen.bytes.len() > eight.bytes.len()); } @@ -267,8 +284,8 @@ mod tests { let mut high = settings(ExportFormat::Jpeg); high.quality = 98; - let small = export(&frame(128, 128), &low, "a".into()).unwrap(); - let large = export(&frame(128, 128), &high, "a".into()).unwrap(); + let small = export(&frame(128, 128), &low, "a".into(), None).unwrap(); + let large = export(&frame(128, 128), &high, "a".into(), None).unwrap(); assert!( large.bytes.len() > small.bytes.len(), "quality 98 produced {} bytes against quality 20's {}", @@ -281,7 +298,7 @@ mod tests { fn a_long_edge_export_lands_on_the_requested_size() { let mut s = settings(ExportFormat::Png); s.sizing = SizingMode::LongEdge(32); - let out = export(&frame(128, 64), &s, "a".into()).unwrap(); + let out = export(&frame(128, 64), &s, "a".into(), None).unwrap(); assert_eq!((out.width, out.height), (32, 16)); } @@ -293,7 +310,7 @@ mod tests { let mut s = settings(ExportFormat::Jpeg); s.colour_space = ColourSpace::DisplayP3; assert!(matches!( - export(&frame(8, 8), &s, "a".into()), + export(&frame(8, 8), &s, "a".into(), None), Err(ExportError::ColourSpaceMismatch { .. }) )); } @@ -314,7 +331,7 @@ mod tests { s.colour_space = space; let mut f = frame(8, 8); f.space = space; - let out = export(&f, &s, "a".into()) + let out = export(&f, &s, "a".into(), None) .unwrap_or_else(|e| panic!("{space:?} as {format:?}: {e}")); assert!(!out.bytes.is_empty()); } @@ -326,7 +343,7 @@ mod tests { for format in [ExportFormat::Avif, ExportFormat::JpegXl] { assert!( matches!( - export(&frame(8, 8), &settings(format), "a".into()), + export(&frame(8, 8), &settings(format), "a".into(), None), Err(ExportError::FormatUnsupported(_)) ), "{format:?} should report that it has no encoder yet" @@ -339,7 +356,7 @@ mod tests { // Walks `ExportFormat::ALL`, so a format added to the settings page // cannot quietly reach an encoder that does not handle it. for format in ExportFormat::ALL { - match export(&frame(8, 8), &settings(format), "a".into()) { + match export(&frame(8, 8), &settings(format), "a".into(), None) { Ok(out) => assert!(!out.bytes.is_empty(), "{format:?} encoded to nothing"), Err(ExportError::FormatUnsupported(f)) => assert_eq!(f, format), Err(e) => panic!("{format:?} failed unexpectedly: {e}"), diff --git a/core/dr-export/src/metadata.rs b/core/dr-export/src/metadata.rs new file mode 100644 index 0000000..a8bde43 --- /dev/null +++ b/core/dr-export/src/metadata.rs @@ -0,0 +1,106 @@ +//! TRACES: FR-EXP-8 +//! What an export is allowed to say about where it came from. +//! +//! # An allowlist, not a filter +//! +//! [`SourceMetadata`] is the whole of what can reach a file this crate writes. +//! It is populated field by field from whatever the caller decoded, and +//! nothing else travels — not because each unwanted tag is removed, but +//! because there is nowhere in this type for one to sit. That is the +//! difference between "we strip GPS" and "GPS cannot be written unless +//! [`SourceMetadata::location`] is `Some`", and only the second survives +//! somebody adding a field to the decoder next year. +//! +//! # What is deliberately not here +//! +//! **The maker note** (EXIF `0x927C`). It is an opaque vendor blob with no +//! public format, and its contents differ by body and firmware. Canon's +//! carries the body serial number and the shutter count; several bodies put a +//! *duplicate copy of the GPS fix* inside it, which is the specific reason it +//! cannot be passed through as an unexamined byte range: an export that +//! stripped the GPS directory and copied the maker note would have published +//! the coordinates anyway, while reporting itself as private. Parsing it per +//! vendor to decide what is safe is a research project with a permanent +//! maintenance cost, and the value on the other side is a few tags a +//! photographer rarely misses. So it is dropped, in both directions, whatever +//! the settings say. +//! +//! **Serial numbers and owner name** (`BodySerialNumber` 0xA431, +//! `LensSerialNumber` 0xA435, `CameraOwnerName` 0xA430). These identify a +//! person and a specific piece of equipment, and a serial number in a +//! published file links every photograph that person has ever posted. They +//! have no field here, so no export writes them. +//! +//! **IPTC and XMP.** FR-EXP-8 names both. Neither is read by `dr-decode` +//! today, so there is nothing to carry through; when there is, it arrives as +//! fields on this type and is written from them, and the same allowlist +//! reasoning applies unchanged. + +use dr_types::Location; + +/// TRACES: FR-EXP-8 +/// The source metadata an export may carry. +/// +/// Every field is optional because every field is genuinely absent from some +/// real file: scanner output has no aperture, a JPEG from a phone has no lens +/// model, and most photographs have no copyright statement at all. +/// +/// Built by the caller, which is the only place that has both the decoded +/// source and the crate that decoded it — `dr-export` deliberately depends on +/// no decoder (see the crate docs), so the copy is made one field at a time +/// where both types are in scope. That transcription is a feature: it is the +/// point where somebody has to decide, in writing, that a newly-parsed piece +/// of the source is allowed to leave the machine. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct SourceMetadata { + pub make: Option, + pub model: Option, + pub lens: Option, + /// Exposure time in seconds. + pub shutter: Option, + /// The f-number, as in f/2.8. + pub aperture: Option, + pub iso: Option, + /// Millimetres, as marked on the lens rather than 35 mm equivalent. + pub focal_length: Option, + /// When the shutter fired, as Unix seconds read as a wall clock. + pub captured_at: Option, + /// Minutes east of UTC, where the camera recorded a zone. + pub captured_offset: Option, + /// Who made the photograph. + pub artist: Option, + /// The rights statement. + pub copyright: Option, + /// TRACES: FR-EXP-8 + /// Where the shutter fired. + /// + /// The one field the strip option is about. It is carried this far so that + /// a photographer who *wants* their coordinates can have them; by the time + /// the encoder sees the record this field has already been through + /// [`Self::sanitised`], and is `None` unless the user turned stripping + /// off. + pub location: Option, +} + +impl SourceMetadata { + /// This record as the settings permit it to be written. + /// + /// **The single place stripping happens.** The encoders below take a + /// record and write what is in it, with no view on privacy; concentrating + /// the decision here means there is one function to read to know what an + /// export can disclose, and no format can quietly disagree with the + /// others — the failure mode where JPEG honours the setting and TIFF, five + /// hundred lines away, does not. + /// + /// Stripping empties the field rather than blanking it. A `GPSLatitude` of + /// `0/0` still announces that the camera had a fix and that this file has + /// been through a scrubber; an absent directory says nothing at all, and + /// says it in the same shape as the millions of files that never had one. + pub(crate) fn sanitised(&self, strip_location: bool) -> Self { + let mut out = self.clone(); + if strip_location { + out.location = None; + } + out + } +} diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index b24c3fa..c3ba27f 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -437,6 +437,51 @@ impl Orientation { } } +/// TRACES: FR-EXP-8 +/// Where a photograph was taken. +/// +/// Here rather than in `dr-decode` because two crates that never speak to each +/// other both need it: the decoder reads it out of the EXIF GPS directory, and +/// the exporter decides whether to write it back. A type in either one would +/// have made the other depend on it. +/// +/// **Signed degrees, not the tag's own shape.** EXIF stores three rationals +/// and a hemisphere letter — `48/1 51/1 2952/100` and `"N"` — which is a +/// representation, not a position. Normalising at the point of parsing means +/// nothing downstream can forget the letter and put a Sydney photograph in +/// the North Atlantic. Positive is north and east. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Location { + /// Degrees north of the equator, -90..=90. + pub latitude: f64, + /// Degrees east of Greenwich, -180..=180. + pub longitude: f64, + /// Metres above sea level, where the file recorded one. Below sea level + /// is negative, which is the reason this is signed and the tag is not. + pub altitude: Option, +} + +impl Location { + /// A position, or `None` where the numbers cannot be one. + /// + /// A GPS directory with an out-of-range value is a corrupt one, and a + /// latitude of 3000 placed on a map is a worse answer than no map pin. + pub fn new(latitude: f64, longitude: f64, altitude: Option) -> Option { + if !latitude.is_finite() + || !longitude.is_finite() + || !(-90.0..=90.0).contains(&latitude) + || !(-180.0..=180.0).contains(&longitude) + { + return None; + } + Some(Self { + latitude, + longitude, + altitude: altitude.filter(|a| a.is_finite()), + }) + } +} + /// An opaque change-validator for a remote entry (an ETag, or an mtime where /// no ETag exists). /// diff --git a/core/dr-types/src/settings.rs b/core/dr-types/src/settings.rs index e2eb157..62f7c0d 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -189,6 +189,25 @@ pub struct ExportSettings { /// What to do when the output filename already exists. pub collision: CollisionPolicy, + /// TRACES: FR-EXP-8 + /// Whether the source's camera, lens, capture time and rights statement + /// are written into the export. + /// + /// On by default, and the two metadata settings are deliberately not one. + /// They answer different questions: this one is "should the copy I hand + /// over say what took it and who owns it", where the answer for a + /// photographer is nearly always yes, and [`Self::strip_location`] is + /// "should it say where I was", where the answer is nearly always no. A + /// single switch would force those together and make the safe choice for + /// one the wrong choice for the other — either publishing coordinates with + /// the copyright notice, or dropping the copyright notice to hide the + /// coordinates. + /// + /// Off writes no metadata block whatsoever, which is what a file destined + /// for somewhere it must give nothing away wants: not an EXIF block that + /// has been emptied, but no EXIF block. + pub retain_metadata: bool, + /// Whether GPS and other identifying metadata is stripped (FR-EXP-8). /// /// Stripping is *on* by default, which is the one place here that departs @@ -245,6 +264,7 @@ impl Default for ExportSettings { sharpening: OutputSharpening::Screen, filename_template: "{name}".to_string(), collision: CollisionPolicy::Increment, + retain_metadata: true, strip_location: true, target: ExportTarget::default(), destination: String::new(), @@ -778,6 +798,27 @@ mod tests { assert!(ExportSettings::default().strip_location); } + #[test] + fn the_camera_is_kept_by_default_and_the_place_is_not() { + // FR-EXP-8's two halves, and the reason they are two settings. The + // defaults have to disagree: an export says what took the photograph + // and stays quiet about where it was taken. + let s = ExportSettings::default(); + assert!(s.retain_metadata, "camera and copyright default to kept"); + assert!(s.strip_location, "coordinates default to stripped"); + } + + #[test] + fn a_settings_file_written_before_metadata_retention_existed_keeps_the_camera() { + // The field is newer than files on disk. `#[serde(default)]` fills it + // from `Default`, and this pins that the fill is the useful direction: + // an upgrade must not silently start writing bare files. + let older = r#"{"format":"jpeg","quality":90,"strip_location":true}"#; + let s: ExportSettings = serde_json::from_str(older).expect("older settings parse"); + assert!(s.retain_metadata); + assert!(s.strip_location); + } + #[test] fn exports_go_to_this_device_unless_asked_otherwise() { // A first run must not upload someone's pictures to a server because From 45214d3ca8c33f0de8606e7864a03ebcb97cbfea Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:01:39 +0200 Subject: [PATCH 10/27] Regenerate the traceability matrix Four branches merged, each of which had regenerated this file against its own tree. Those versions all disagreed and none was right for the union, which is why the merges took whichever side was to hand and deferred to this: the matrix is generated, so the only correct version is the one produced once, here, from the merged source. --- docs/traceability.md | 106 +++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/docs/traceability.md b/docs/traceability.md index dc5910a..669bad6 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 148 | -| TRACES tags found | 344 | +| Source files scanned | 160 | +| TRACES tags found | 436 | | Requirements defined | 175 | -| Requirements covered | 83 | -| **Coverage** | **47.4%** (83/175) | +| Requirements covered | 86 | +| **Coverage** | **49.1%** (86/175) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 65 | 120 | +| FR | 68 | 120 | | NFR | 16 | 49 | | R | 2 | 6 | @@ -35,71 +35,74 @@ _None._ |---|---| | FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/walk.rs:109`](../core/dr-catalog/src/walk.rs#L109), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479), [`tools/traceability/src/lib.rs:511`](../tools/traceability/src/lib.rs#L511), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) | | FR-CAT-11 | [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152) | -| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:109`](../core/dr-pipeline/src/sidecar.rs#L109) | -| 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:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`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:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2576`](../ui/dr-ui/src/library.rs#L2576), [`ui/dr-ui/src/library.rs:2608`](../ui/dr-ui/src/library.rs#L2608), [`ui/dr-ui/src/library_ui.rs:123`](../ui/dr-ui/src/library_ui.rs#L123), [`ui/dr-ui/src/library_ui.rs:637`](../ui/dr-ui/src/library_ui.rs#L637), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | +| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111) | +| FR-CAT-13 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:365`](../core/dr-catalog/src/schema.rs#L365), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:447`](../core/dr-sync-nextcloud/src/lib.rs#L447), [`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:376`](../core/dr-thumbs/src/lib.rs#L376), [`ui/dr-ui/src/collections_ui.rs:1171`](../ui/dr-ui/src/collections_ui.rs#L1171), [`ui/dr-ui/src/collections_ui.rs:1846`](../ui/dr-ui/src/collections_ui.rs#L1846), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:169`](../ui/dr-ui/src/library.rs#L169), [`ui/dr-ui/src/library.rs:2576`](../ui/dr-ui/src/library.rs#L2576), [`ui/dr-ui/src/library.rs:2608`](../ui/dr-ui/src/library.rs#L2608), [`ui/dr-ui/src/library_ui.rs:123`](../ui/dr-ui/src/library_ui.rs#L123), [`ui/dr-ui/src/library_ui.rs:637`](../ui/dr-ui/src/library_ui.rs#L637), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:572`](../ui/dr-ui/ui/collections.slint#L572) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) | | 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:479`](../tools/traceability/src/lib.rs#L479) | | FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`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:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-decode/src/lib.rs:310`](../core/dr-decode/src/lib.rs#L310), [`core/dr-pipeline/src/sidecar.rs:126`](../core/dr-pipeline/src/sidecar.rs#L126) | -| 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:179`](../ui/dr-ui/src/library.rs#L179) | -| 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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`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/app.slint:570`](../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:687`](../ui/dr-ui/ui/library.slint#L687) | -| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/develop.rs:1770`](../ui/dr-ui/src/develop.rs#L1770), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1065`](../ui/dr-ui/src/lib.rs#L1065), [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/lib.rs:1510`](../ui/dr-ui/src/lib.rs#L1510), [`ui/dr-ui/src/lib.rs:460`](../ui/dr-ui/src/lib.rs#L460), [`ui/dr-ui/src/lib.rs:748`](../ui/dr-ui/src/lib.rs#L748), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4071`](../ui/dr-ui/src/library_ui.rs#L4071), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| 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-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`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:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1478`](../ui/dr-ui/src/develop.rs#L1478), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2377`](../ui/dr-ui/src/library_ui.rs#L2377), [`ui/dr-ui/src/library_ui.rs:2559`](../ui/dr-ui/src/library_ui.rs#L2559), [`ui/dr-ui/src/library_ui.rs:2840`](../ui/dr-ui/src/library_ui.rs#L2840), [`ui/dr-ui/src/library_ui.rs:2912`](../ui/dr-ui/src/library_ui.rs#L2912), [`ui/dr-ui/src/library_ui.rs:3077`](../ui/dr-ui/src/library_ui.rs#L3077), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4267`](../ui/dr-ui/src/library_ui.rs#L4267), [`ui/dr-ui/src/library_ui.rs:4382`](../ui/dr-ui/src/library_ui.rs#L4382), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs:264`](../core/dr-decode/src/lib.rs#L264), [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library_ui.rs:5224`](../ui/dr-ui/src/library_ui.rs#L5224), [`ui/dr-ui/src/library_ui.rs:5235`](../ui/dr-ui/src/library_ui.rs#L5235), [`ui/dr-ui/src/library_ui.rs:5248`](../ui/dr-ui/src/library_ui.rs#L5248), [`ui/dr-ui/src/library_ui.rs:5263`](../ui/dr-ui/src/library_ui.rs#L5263), [`ui/dr-ui/src/library_ui.rs:5272`](../ui/dr-ui/src/library_ui.rs#L5272), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.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-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | +| 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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`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/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:716`](../ui/dr-ui/ui/library.slint#L716) | +| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:141`](../core/dr-pipeline/src/ops/curve.rs#L141), [`core/dr-pipeline/src/ops/curve.rs:657`](../core/dr-pipeline/src/ops/curve.rs#L657), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/tests/tone_curve.rs:32`](../core/dr-pipeline/tests/tone_curve.rs#L32), [`ui/dr-ui/src/develop.rs:2194`](../ui/dr-ui/src/develop.rs#L2194), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/lib.rs:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/lib.rs:1528`](../ui/dr-ui/src/lib.rs#L1528), [`ui/dr-ui/src/lib.rs:462`](../ui/dr-ui/src/lib.rs#L462), [`ui/dr-ui/src/lib.rs:766`](../ui/dr-ui/src/lib.rs#L766), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4235`](../ui/dr-ui/src/library_ui.rs#L4235), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| 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:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`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:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1902`](../ui/dr-ui/src/develop.rs#L1902), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2541`](../ui/dr-ui/src/library_ui.rs#L2541), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:3004`](../ui/dr-ui/src/library_ui.rs#L3004), [`ui/dr-ui/src/library_ui.rs:3076`](../ui/dr-ui/src/library_ui.rs#L3076), [`ui/dr-ui/src/library_ui.rs:3241`](../ui/dr-ui/src/library_ui.rs#L3241), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4465`](../ui/dr-ui/src/library_ui.rs#L4465), [`ui/dr-ui/src/library_ui.rs:4580`](../ui/dr-ui/src/library_ui.rs#L4580), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | 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:126`](../core/dr-pipeline/src/sidecar.rs#L126), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1388`](../core/dr-gpu/src/adjust.rs#L1388), [`core/dr-gpu/src/adjust.rs:297`](../core/dr-gpu/src/adjust.rs#L297), [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186), [`core/dr-pipeline/src/operation.rs:196`](../core/dr-pipeline/src/operation.rs#L196), [`core/dr-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`ui/dr-ui/src/develop.rs:1016`](../ui/dr-ui/src/develop.rs#L1016), [`ui/dr-ui/src/develop.rs:62`](../ui/dr-ui/src/develop.rs#L62), [`ui/dr-ui/src/develop.rs:867`](../ui/dr-ui/src/develop.rs#L867), [`ui/dr-ui/src/lib.rs:1101`](../ui/dr-ui/src/lib.rs#L1101), [`ui/dr-ui/src/lib.rs:1753`](../ui/dr-ui/src/lib.rs#L1753), [`ui/dr-ui/src/lib.rs:286`](../ui/dr-ui/src/lib.rs#L286) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107), [`ui/dr-ui/src/lib.rs:522`](../ui/dr-ui/src/lib.rs#L522) | -| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`ui/dr-ui/src/develop.rs:2579`](../ui/dr-ui/src/develop.rs#L2579) | -| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186) | -| FR-DEV-3e | [`core/dr-decode/src/lib.rs:504`](../core/dr-decode/src/lib.rs#L504), [`core/dr-decode/src/lib.rs:646`](../core/dr-decode/src/lib.rs#L646), [`core/dr-decode/src/lib.rs:686`](../core/dr-decode/src/lib.rs#L686) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:310`](../core/dr-decode/src/lib.rs#L310), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:200`](../core/dr-pipeline/src/framing.rs#L200), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | -| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:211`](../core/dr-gpu/src/lib.rs#L211) | -| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:1786`](../ui/dr-ui/src/develop.rs#L1786), [`ui/dr-ui/src/develop.rs:1796`](../ui/dr-ui/src/develop.rs#L1796), [`ui/dr-ui/src/develop.rs:44`](../ui/dr-ui/src/develop.rs#L44), [`ui/dr-ui/src/lib.rs:1093`](../ui/dr-ui/src/lib.rs#L1093) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:1747`](../ui/dr-ui/src/develop.rs#L1747), [`ui/dr-ui/src/develop.rs:1757`](../ui/dr-ui/src/develop.rs#L1757), [`ui/dr-ui/src/lib.rs:1065`](../ui/dr-ui/src/lib.rs#L1065), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2208`](../ui/dr-ui/src/library_ui.rs#L2208), [`ui/dr-ui/src/library_ui.rs:2559`](../ui/dr-ui/src/library_ui.rs#L2559), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:569`](../ui/dr-ui/ui/adjust.slint#L569), [`ui/dr-ui/ui/library.slint:1037`](../ui/dr-ui/ui/library.slint#L1037), [`ui/dr-ui/ui/library.slint:640`](../ui/dr-ui/ui/library.slint#L640), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1311`](../core/dr-gpu/src/adjust.rs#L1311), [`core/dr-gpu/src/adjust.rs:1388`](../core/dr-gpu/src/adjust.rs#L1388), [`core/dr-gpu/src/adjust.rs:1473`](../core/dr-gpu/src/adjust.rs#L1473), [`core/dr-gpu/src/adjust.rs:42`](../core/dr-gpu/src/adjust.rs#L42), [`core/dr-gpu/src/lib.rs:48`](../core/dr-gpu/src/lib.rs#L48), [`core/dr-gpu/src/lib.rs:88`](../core/dr-gpu/src/lib.rs#L88), [`ui/dr-ui/src/develop.rs:1314`](../ui/dr-ui/src/develop.rs#L1314), [`ui/dr-ui/src/develop.rs:1934`](../ui/dr-ui/src/develop.rs#L1934), [`ui/dr-ui/src/develop.rs:2124`](../ui/dr-ui/src/develop.rs#L2124), [`ui/dr-ui/src/develop.rs:2158`](../ui/dr-ui/src/develop.rs#L2158), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56), [`ui/dr-ui/src/lib.rs:647`](../ui/dr-ui/src/lib.rs#L647) | -| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:169`](../core/dr-pipeline/src/operation.rs#L169), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1359`](../ui/dr-ui/src/develop.rs#L1359), [`ui/dr-ui/src/develop.rs:3084`](../ui/dr-ui/src/develop.rs#L3084), [`ui/dr-ui/src/develop.rs:3116`](../ui/dr-ui/src/develop.rs#L3116), [`ui/dr-ui/src/develop.rs:55`](../ui/dr-ui/src/develop.rs#L55), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1152`](../ui/dr-ui/src/lib.rs#L1152), [`ui/dr-ui/src/lib.rs:281`](../ui/dr-ui/src/lib.rs#L281), [`ui/dr-ui/ui/app.slint:303`](../ui/dr-ui/ui/app.slint#L303), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | +| FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1768`](../core/dr-gpu/src/adjust.rs#L1768), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:333`](../core/dr-pipeline/src/detail.rs#L333), [`core/dr-pipeline/src/detail.rs:408`](../core/dr-pipeline/src/detail.rs#L408), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1255`](../ui/dr-ui/src/develop.rs#L1255), [`ui/dr-ui/src/develop.rs:1270`](../ui/dr-ui/src/develop.rs#L1270), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1375`](../ui/dr-ui/src/develop.rs#L1375), [`ui/dr-ui/src/develop.rs:1449`](../ui/dr-ui/src/develop.rs#L1449), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2552`](../ui/dr-ui/src/develop.rs#L2552), [`ui/dr-ui/src/develop.rs:2606`](../ui/dr-ui/src/develop.rs#L2606), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1119`](../ui/dr-ui/src/lib.rs#L1119), [`ui/dr-ui/src/lib.rs:1771`](../ui/dr-ui/src/lib.rs#L1771), [`ui/dr-ui/src/lib.rs:288`](../ui/dr-ui/src/lib.rs#L288), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:925`](../core/dr-pipeline/src/mask.rs#L925), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:524`](../ui/dr-ui/src/lib.rs#L524) | +| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:925`](../core/dr-pipeline/src/mask.rs#L925), [`ui/dr-ui/src/develop.rs:3185`](../ui/dr-ui/src/develop.rs#L3185) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/detail_stage.rs:240`](../core/dr-gpu/tests/detail_stage.rs#L240), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:420`](../core/dr-pipeline/src/graph.rs#L420), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | +| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:100`](../core/dr-decode/src/lib.rs#L100), [`core/dr-decode/src/lib.rs:632`](../core/dr-decode/src/lib.rs#L632), [`core/dr-decode/src/lib.rs:672`](../core/dr-decode/src/lib.rs#L672), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1344`](../core/dr-pipeline/src/operation.rs#L1344), [`core/dr-pipeline/src/operation.rs:1369`](../core/dr-pipeline/src/operation.rs#L1369), [`core/dr-pipeline/src/operation.rs:1384`](../core/dr-pipeline/src/operation.rs#L1384), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:513`](../core/dr-pipeline/src/operation.rs#L513) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | +| FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | +| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2210`](../ui/dr-ui/src/develop.rs#L2210), [`ui/dr-ui/src/develop.rs:2220`](../ui/dr-ui/src/develop.rs#L2220), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/lib.rs:1111`](../ui/dr-ui/src/lib.rs#L1111) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:2171`](../ui/dr-ui/src/develop.rs#L2171), [`ui/dr-ui/src/develop.rs:2181`](../ui/dr-ui/src/develop.rs#L2181), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2372`](../ui/dr-ui/src/library_ui.rs#L2372), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1077`](../ui/dr-ui/ui/library.slint#L1077), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:737`](../ui/dr-ui/ui/library.slint#L737), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | +| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:333`](../core/dr-pipeline/src/detail.rs#L333), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1691`](../core/dr-gpu/src/adjust.rs#L1691), [`core/dr-gpu/src/adjust.rs:1768`](../core/dr-gpu/src/adjust.rs#L1768), [`core/dr-gpu/src/adjust.rs:1853`](../core/dr-gpu/src/adjust.rs#L1853), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:408`](../core/dr-pipeline/src/detail.rs#L408), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`ui/dr-ui/src/develop.rs:1738`](../ui/dr-ui/src/develop.rs#L1738), [`ui/dr-ui/src/develop.rs:2358`](../ui/dr-ui/src/develop.rs#L2358), [`ui/dr-ui/src/develop.rs:2730`](../ui/dr-ui/src/develop.rs#L2730), [`ui/dr-ui/src/develop.rs:2764`](../ui/dr-ui/src/develop.rs#L2764), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57), [`ui/dr-ui/src/lib.rs:665`](../ui/dr-ui/src/lib.rs#L665) | +| FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1783`](../ui/dr-ui/src/develop.rs#L1783), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3832`](../ui/dr-ui/src/develop.rs#L3832), [`ui/dr-ui/src/develop.rs:3864`](../ui/dr-ui/src/develop.rs#L3864), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1170`](../ui/dr-ui/src/lib.rs#L1170), [`ui/dr-ui/src/lib.rs:283`](../ui/dr-ui/src/lib.rs#L283), [`ui/dr-ui/ui/app.slint:304`](../ui/dr-ui/ui/app.slint#L304), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`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-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:1621`](../core/dr-gpu/src/adjust.rs#L1621), [`core/dr-pipeline/src/graph.rs:336`](../core/dr-pipeline/src/graph.rs#L336), [`core/dr-pipeline/src/operation.rs:169`](../core/dr-pipeline/src/operation.rs#L169), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:454`](../core/dr-types/src/settings.rs#L454), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:2001`](../core/dr-gpu/src/adjust.rs#L2001), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:454`](../core/dr-types/src/settings.rs#L454), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`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-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.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-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1676`](../ui/dr-ui/src/lib.rs#L1676), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324), [`ui/dr-ui/src/lib.rs:359`](../ui/dr-ui/src/lib.rs#L359), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library_ui.rs:2929`](../ui/dr-ui/src/library_ui.rs#L2929), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5008`](../ui/dr-ui/src/library_ui.rs#L5008), [`ui/dr-ui/src/library_ui.rs:5020`](../ui/dr-ui/src/library_ui.rs#L5020), [`ui/dr-ui/src/library_ui.rs:5032`](../ui/dr-ui/src/library_ui.rs#L5032), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1180`](../ui/dr-ui/ui/app.slint#L1180), [`ui/dr-ui/ui/app.slint:869`](../ui/dr-ui/ui/app.slint#L869), [`ui/dr-ui/ui/library.slint:1045`](../ui/dr-ui/ui/library.slint#L1045), [`ui/dr-ui/ui/library.slint:644`](../ui/dr-ui/ui/library.slint#L644), [`ui/dr-ui/ui/library.slint:712`](../ui/dr-ui/ui/library.slint#L712) | +| FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1694`](../ui/dr-ui/src/lib.rs#L1694), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326), [`ui/dr-ui/src/lib.rs:361`](../ui/dr-ui/src/lib.rs#L361), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5206`](../ui/dr-ui/src/library_ui.rs#L5206), [`ui/dr-ui/src/library_ui.rs:5283`](../ui/dr-ui/src/library_ui.rs#L5283), [`ui/dr-ui/src/library_ui.rs:5295`](../ui/dr-ui/src/library_ui.rs#L5295), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1243`](../ui/dr-ui/ui/app.slint#L1243), [`ui/dr-ui/ui/app.slint:932`](../ui/dr-ui/ui/app.slint#L932), [`ui/dr-ui/ui/library.slint:1085`](../ui/dr-ui/ui/library.slint#L1085), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) | | FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`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:412`](../core/dr-decode/src/lib.rs#L412), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:415`](../core/dr-gpu/src/adjust.rs#L415), [`ui/dr-ui/src/develop.rs:1437`](../ui/dr-ui/src/develop.rs#L1437), [`ui/dr-ui/src/lib.rs:324`](../ui/dr-ui/src/lib.rs#L324) | +| FR-EXP-9 | [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:1861`](../ui/dr-ui/src/develop.rs#L1861), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326) | | 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-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:386`](../ui/dr-ui/src/lib.rs#L386), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:2929`](../ui/dr-ui/src/library_ui.rs#L2929), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.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:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`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: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-6 | [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1) | -| 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/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1436`](../ui/dr-ui/src/lib.rs#L1436), [`ui/dr-ui/src/lib.rs:2131`](../ui/dr-ui/src/lib.rs#L2131), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:1118`](../ui/dr-ui/src/library_ui.rs#L1118), [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173), [`ui/dr-ui/src/library_ui.rs:1291`](../ui/dr-ui/src/library_ui.rs#L1291), [`ui/dr-ui/src/library_ui.rs:1410`](../ui/dr-ui/src/library_ui.rs#L1410), [`ui/dr-ui/src/library_ui.rs:1888`](../ui/dr-ui/src/library_ui.rs#L1888), [`ui/dr-ui/src/library_ui.rs:206`](../ui/dr-ui/src/library_ui.rs#L206), [`ui/dr-ui/src/library_ui.rs:217`](../ui/dr-ui/src/library_ui.rs#L217), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:237`](../ui/dr-ui/src/library_ui.rs#L237), [`ui/dr-ui/src/library_ui.rs:246`](../ui/dr-ui/src/library_ui.rs#L246), [`ui/dr-ui/src/library_ui.rs:311`](../ui/dr-ui/src/library_ui.rs#L311), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:363`](../ui/dr-ui/src/library_ui.rs#L363), [`ui/dr-ui/src/library_ui.rs:426`](../ui/dr-ui/src/library_ui.rs#L426), [`ui/dr-ui/src/library_ui.rs:4284`](../ui/dr-ui/src/library_ui.rs#L4284), [`ui/dr-ui/src/library_ui.rs:4302`](../ui/dr-ui/src/library_ui.rs#L4302), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`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/app.slint:2026`](../ui/dr-ui/ui/app.slint#L2026), [`ui/dr-ui/ui/app.slint:564`](../ui/dr-ui/ui/app.slint#L564), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:758`](../ui/dr-ui/ui/library.slint#L758) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/schema.rs:738`](../core/dr-catalog/src/schema.rs#L738), [`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/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/lib.rs:1454`](../ui/dr-ui/src/lib.rs#L1454), [`ui/dr-ui/src/lib.rs:2194`](../ui/dr-ui/src/lib.rs#L2194), [`ui/dr-ui/src/library.rs:1254`](../ui/dr-ui/src/library.rs#L1254), [`ui/dr-ui/src/library.rs:1277`](../ui/dr-ui/src/library.rs#L1277), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:1118`](../ui/dr-ui/src/library_ui.rs#L1118), [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173), [`ui/dr-ui/src/library_ui.rs:1291`](../ui/dr-ui/src/library_ui.rs#L1291), [`ui/dr-ui/src/library_ui.rs:1410`](../ui/dr-ui/src/library_ui.rs#L1410), [`ui/dr-ui/src/library_ui.rs:1888`](../ui/dr-ui/src/library_ui.rs#L1888), [`ui/dr-ui/src/library_ui.rs:206`](../ui/dr-ui/src/library_ui.rs#L206), [`ui/dr-ui/src/library_ui.rs:217`](../ui/dr-ui/src/library_ui.rs#L217), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:237`](../ui/dr-ui/src/library_ui.rs#L237), [`ui/dr-ui/src/library_ui.rs:246`](../ui/dr-ui/src/library_ui.rs#L246), [`ui/dr-ui/src/library_ui.rs:311`](../ui/dr-ui/src/library_ui.rs#L311), [`ui/dr-ui/src/library_ui.rs:321`](../ui/dr-ui/src/library_ui.rs#L321), [`ui/dr-ui/src/library_ui.rs:363`](../ui/dr-ui/src/library_ui.rs#L363), [`ui/dr-ui/src/library_ui.rs:426`](../ui/dr-ui/src/library_ui.rs#L426), [`ui/dr-ui/src/library_ui.rs:4482`](../ui/dr-ui/src/library_ui.rs#L4482), [`ui/dr-ui/src/library_ui.rs:4500`](../ui/dr-ui/src/library_ui.rs#L4500), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/src/library_ui.rs:457`](../ui/dr-ui/src/library_ui.rs#L457), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`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/app.slint:2247`](../ui/dr-ui/ui/app.slint#L2247), [`ui/dr-ui/ui/app.slint:581`](../ui/dr-ui/ui/app.slint#L581), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:369`](../ui/dr-ui/ui/collections.slint#L369), [`ui/dr-ui/ui/collections.slint:52`](../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/collections.slint:84`](../ui/dr-ui/ui/collections.slint#L84), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260), [`ui/dr-ui/ui/library.slint:798`](../ui/dr-ui/ui/library.slint#L798) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1173`](../ui/dr-ui/src/library_ui.rs#L1173) | | 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:115`](../core/dr-types/src/lib.rs#L115), [`core/dr-types/src/lib.rs:197`](../core/dr-types/src/lib.rs#L197), [`ui/dr-ui/src/activity.rs:1`](../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui.rs:2963`](../ui/dr-ui/src/collections_ui.rs#L2963), [`ui/dr-ui/src/collections_ui.rs:574`](../ui/dr-ui/src/collections_ui.rs#L574), [`ui/dr-ui/src/collections_ui.rs:636`](../ui/dr-ui/src/collections_ui.rs#L636), [`ui/dr-ui/src/library_ui.rs:1017`](../ui/dr-ui/src/library_ui.rs#L1017), [`ui/dr-ui/src/library_ui.rs:944`](../ui/dr-ui/src/library_ui.rs#L944), [`ui/dr-ui/ui/collections.slint:249`](../ui/dr-ui/ui/collections.slint#L249), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682), [`ui/dr-ui/ui/icons.slint:260`](../ui/dr-ui/ui/icons.slint#L260) | | 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:109`](../core/dr-pipeline/src/sidecar.rs#L109), [`core/dr-pipeline/src/sidecar.rs:90`](../core/dr-pipeline/src/sidecar.rs#L90), [`ui/dr-ui/src/lib.rs:1405`](../ui/dr-ui/src/lib.rs#L1405), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378) | -| FR-NC-9 | [`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-pipeline/src/sidecar.rs:147`](../core/dr-pipeline/src/sidecar.rs#L147), [`core/dr-pipeline/src/sidecar.rs:301`](../core/dr-pipeline/src/sidecar.rs#L301), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) | +| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/lib.rs:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/src/sidecar.rs:303`](../core/dr-pipeline/src/sidecar.rs#L303), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:810`](../ui/dr-ui/src/library.rs#L810) | | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) | | 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:201`](../core/dr-decode/src/lib.rs#L201), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) | -| FR-RAW-3 | [`core/dr-decode/src/lib.rs:412`](../core/dr-decode/src/lib.rs#L412), [`core/dr-decode/src/lib.rs:97`](../core/dr-decode/src/lib.rs#L97), [`core/dr-decode/src/locate.rs:1045`](../core/dr-decode/src/locate.rs#L1045) | -| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:172`](../ui/dr-ui/src/lib.rs#L172) | -| FR-RAW-5 | [`core/dr-decode/src/lib.rs:125`](../core/dr-decode/src/lib.rs#L125), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:573`](../core/dr-gpu/src/demosaic.rs#L573), [`core/dr-gpu/src/demosaic.rs:652`](../core/dr-gpu/src/demosaic.rs#L652), [`core/dr-gpu/src/demosaic.rs:776`](../core/dr-gpu/src/demosaic.rs#L776) | -| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2213`](../ui/dr-ui/src/lib.rs#L2213), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/ui/library.slint:804`](../ui/dr-ui/ui/library.slint#L804) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:64`](../ui/dr-ui/src/lib.rs#L64), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/ui/app.slint:575`](../ui/dr-ui/ui/app.slint#L575), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/library.slint:1784`](../ui/dr-ui/ui/library.slint#L1784), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/library.slint:954`](../ui/dr-ui/ui/library.slint#L954), [`ui/dr-ui/ui/library.slint:960`](../ui/dr-ui/ui/library.slint#L960) | -| FR-UI-3 | [`ui/dr-ui/src/library_ui.rs:3590`](../ui/dr-ui/src/library_ui.rs#L3590), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | -| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3590`](../ui/dr-ui/src/library_ui.rs#L3590), [`ui/dr-ui/src/library_ui.rs:3635`](../ui/dr-ui/src/library_ui.rs#L3635), [`ui/dr-ui/src/library_ui.rs:3738`](../ui/dr-ui/src/library_ui.rs#L3738), [`ui/dr-ui/src/library_ui.rs:3766`](../ui/dr-ui/src/library_ui.rs#L3766), [`ui/dr-ui/src/library_ui.rs:4302`](../ui/dr-ui/src/library_ui.rs#L4302), [`ui/dr-ui/src/library_ui.rs:4314`](../ui/dr-ui/src/library_ui.rs#L4314), [`ui/dr-ui/ui/app.slint:1401`](../ui/dr-ui/ui/app.slint#L1401), [`ui/dr-ui/ui/app.slint:570`](../ui/dr-ui/ui/app.slint#L570), [`ui/dr-ui/ui/app.slint:582`](../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/library.slint:1784`](../ui/dr-ui/ui/library.slint#L1784), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:668`](../ui/dr-ui/ui/library.slint#L668), [`ui/dr-ui/ui/library.slint:687`](../ui/dr-ui/ui/library.slint#L687), [`ui/dr-ui/ui/library.slint:947`](../ui/dr-ui/ui/library.slint#L947), [`ui/dr-ui/ui/library.slint:954`](../ui/dr-ui/ui/library.slint#L954), [`ui/dr-ui/ui/library.slint:960`](../ui/dr-ui/ui/library.slint#L960) | -| 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:2247`](../ui/dr-ui/src/lib.rs#L2247), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | -| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257) | +| FR-RAW-1 | [`core/dr-decode/src/lib.rs:222`](../core/dr-decode/src/lib.rs#L222), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) | +| FR-RAW-3 | [`core/dr-decode/src/lib.rs:118`](../core/dr-decode/src/lib.rs#L118), [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-decode/src/locate.rs:1045`](../core/dr-decode/src/locate.rs#L1045) | +| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173) | +| FR-RAW-5 | [`core/dr-decode/src/lib.rs:146`](../core/dr-decode/src/lib.rs#L146), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | +| FR-UI-1 | [`ui/dr-ui/src/lib.rs:2276`](../ui/dr-ui/src/lib.rs#L2276), [`ui/dr-ui/src/lib.rs:65`](../ui/dr-ui/src/lib.rs#L65), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:844`](../ui/dr-ui/ui/library.slint#L844) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:65`](../ui/dr-ui/src/lib.rs#L65), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/ui/app.slint:611`](../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/app.slint:618`](../ui/dr-ui/ui/app.slint#L618), [`ui/dr-ui/ui/library.slint:1000`](../ui/dr-ui/ui/library.slint#L1000), [`ui/dr-ui/ui/library.slint:1868`](../ui/dr-ui/ui/library.slint#L1868), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:987`](../ui/dr-ui/ui/library.slint#L987), [`ui/dr-ui/ui/library.slint:994`](../ui/dr-ui/ui/library.slint#L994) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1449`](../ui/dr-ui/src/develop.rs#L1449), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:792`](../ui/dr-ui/src/masks_ui.rs#L792), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | +| FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/library_ui.rs:3799`](../ui/dr-ui/src/library_ui.rs#L3799), [`ui/dr-ui/src/library_ui.rs:3902`](../ui/dr-ui/src/library_ui.rs#L3902), [`ui/dr-ui/src/library_ui.rs:3930`](../ui/dr-ui/src/library_ui.rs#L3930), [`ui/dr-ui/src/library_ui.rs:4500`](../ui/dr-ui/src/library_ui.rs#L4500), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/ui/app.slint:1468`](../ui/dr-ui/ui/app.slint#L1468), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/app.slint:618`](../ui/dr-ui/ui/app.slint#L618), [`ui/dr-ui/ui/library.slint:1000`](../ui/dr-ui/ui/library.slint#L1000), [`ui/dr-ui/ui/library.slint:1868`](../ui/dr-ui/ui/library.slint#L1868), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:716`](../ui/dr-ui/ui/library.slint#L716), [`ui/dr-ui/ui/library.slint:987`](../ui/dr-ui/ui/library.slint#L987), [`ui/dr-ui/ui/library.slint:994`](../ui/dr-ui/ui/library.slint#L994) | +| 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:1921`](../ui/dr-ui/src/lib.rs#L1921), [`ui/dr-ui/src/lib.rs:2310`](../ui/dr-ui/src/lib.rs#L2310), [`ui/dr-ui/src/lib.rs:2495`](../ui/dr-ui/src/lib.rs#L2495), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:325`](../ui/dr-ui/ui/app.slint#L325), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1710`](../ui/dr-ui/src/lib.rs#L1710), [`ui/dr-ui/ui/app.slint:880`](../ui/dr-ui/ui/app.slint#L880), [`ui/dr-ui/ui/library.slint:712`](../ui/dr-ui/ui/library.slint#L712) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1728`](../ui/dr-ui/src/lib.rs#L1728), [`ui/dr-ui/ui/app.slint:943`](../ui/dr-ui/ui/app.slint#L943), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) | | 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), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | @@ -108,23 +111,22 @@ _None._ | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298) | | NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | -| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | +| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | | NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) | -| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:56`](../ui/dr-ui/src/lib.rs#L56) | -| NFR-RES-4 | [`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-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), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376) | +| NFR-RES-1 | [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57) | +| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:337`](../core/dr-catalog/src/schema.rs#L337), [`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), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376) | | NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) | | R1 | [`tools/traceability/src/lib.rs:495`](../tools/traceability/src/lib.rs#L495), [`tools/traceability/src/lib.rs:499`](../tools/traceability/src/lib.rs#L499) | -| R4 | [`core/dr-gpu/src/lib.rs:211`](../core/dr-gpu/src/lib.rs#L211) | +| R4 | [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | ## Not yet tagged -92 of 175 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +89 of 175 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements - FR-CAT-10 -- FR-CAT-13 - FR-CAT-14 - FR-CULL-10 - FR-CULL-11 @@ -136,11 +138,9 @@ _None._ - FR-CULL-8 - FR-CULL-9 - FR-DEV-1 -- FR-DEV-2 - FR-DEV-3f - FR-DEV-3g - FR-DEV-7 -- FR-DEV-8 - FR-DSP-2 - FR-DSP-3 - FR-DSP-4 From b321556dbe2f7479c5dbc65998a0f7afa0b4f24a Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:13:00 +0200 Subject: [PATCH 11/27] Sharpen the capture with a separable unsharp mask Capture sharpening as a two-pass unsharp mask in the detail stage: blur along x, then along y, each pass applying a one-dimensional high-pass to luminance so the composite preserves a flat field exactly and matches the textbook kernel on any locally one-dimensional edge. The radius is stated in source pixels and converted once per render, so a radius tuned on a fit view is the radius the exported file gets. Below one render pixel the operation declines to draw rather than showing sharpening the file will not contain, and emits a single pass-through that still carries the output transform. The develop session now renders through render_detailed, which is what lets an active neighbourhood operation reach the screen at all. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/tests/capture_sharpen.rs | 8 +- core/dr-pipeline/src/ops/capture_sharpen.rs | 156 +++++++++++++------- ui/dr-ui/src/develop.rs | 38 ++++- 3 files changed, 145 insertions(+), 57 deletions(-) diff --git a/core/dr-gpu/tests/capture_sharpen.rs b/core/dr-gpu/tests/capture_sharpen.rs index 4478e6c..1b101ee 100644 --- a/core/dr-gpu/tests/capture_sharpen.rs +++ b/core/dr-gpu/tests/capture_sharpen.rs @@ -217,10 +217,12 @@ fn a_proxy_and_an_export_sharpen_the_same_photograph() { let Some(ctx) = ctx() else { return }; const SOURCE: u32 = 128; let source = step_edge(&ctx, SOURCE, 90, 150); - // Four source pixels, so that even the half-size proxy has a two-pixel - // sigma and resolves the radius — the honest cut-off is tested in + // The widest radius the slider offers, so that even the half-size proxy + // has a 1.5-pixel sigma and resolves it — the honest cut-off is tested in // `dr-pipeline`, and this test is about the case where both renders draw. - let graph = sharpened(100.0, 4.0, 0.0); + // Asking for more would be asking for a photograph nobody can produce: + // `EditGraph::set_param` clamps to the descriptor on the way in. + let graph = sharpened(100.0, 3.0, 0.0); // The halo, measured against the same edit with no sharpening at the same // size: how far from the transition the picture is still disturbed, as a diff --git a/core/dr-pipeline/src/ops/capture_sharpen.rs b/core/dr-pipeline/src/ops/capture_sharpen.rs index 29a0944..c938fe3 100644 --- a/core/dr-pipeline/src/ops/capture_sharpen.rs +++ b/core/dr-pipeline/src/ops/capture_sharpen.rs @@ -494,7 +494,13 @@ let contrast = abs(high) / level; let knee = max(gate, 1e-5); let keep = select(1.0, smoothstep(knee * 0.5, knee, contrast), gate > 0.0); -let target = centre + amount * high * keep; +// `sharpened` rather than the obvious `target`: `target` is a WGSL reserved +// keyword, and a fragment that declares one fails to compile against +// *generated* source, so the error names a file nobody wrote. The pipeline +// has been bitten by exactly this once already — see +// `no_fragment_declares_a_wgsl_reserved_keyword` in `lib.rs`, which was added +// the day `let target` broke the contrast fragment. +let sharpened = centre + amount * high * keep; // Applied as a gain on all three channels rather than as an offset, so that // steepening an edge does not drag its colour towards grey: the channel ratios @@ -506,29 +512,32 @@ let target = centre + amount * high * keep; // Below a nearly-black luminance the ratio stops carrying information — the // three channels are all noise there and the divisor is meaningless — so the // pixel is handed on untouched rather than multiplied by whatever fell out. -let scaled = c * (max(target, 0.0) / max(centre, 1e-5)); +let scaled = c * (max(sharpened, 0.0) / max(centre, 1e-5)); c = select(c, scaled, centre > 1e-5);"#; #[cfg(test)] mod tests { use super::*; - use crate::detail::compose_detail; + use crate::EditGraph; use dr_types::ColourSpace; - /// The chain with the sharpener turned up, as the composer sees it. - fn sharpening(amount: f32, radius: f32) -> Vec> { - let mut ops = crate::ops::chain(); - for op in &mut ops { - if op.descriptor().id == ID { - op.set_param(AMOUNT, amount); - op.set_param(RADIUS, radius); - } - } - ops + /// The develop chain with the sharpener turned up. + /// + /// Built through [`EditGraph`] rather than by reaching into `ops::chain()` + /// directly, so that every value these tests use passes the same clamp a + /// slider's would. A test asserting an exact kernel for a radius the graph + /// would have clipped on the way in is a test of a configuration the + /// photographer cannot reach — the mistake `build.rs` refuses outright for + /// declared nodes, and which nothing catches for a hand-written one. + fn sharpening(amount: f32, radius: f32) -> EditGraph { + let mut graph = EditGraph::default_chain(); + graph.set_param(ID, AMOUNT, amount); + graph.set_param(ID, RADIUS, radius); + graph } - fn chain_at(ops: &[Box], scale: RenderScale) -> crate::detail::ComposedDetail { - compose_detail(ops, scale, ColourSpace::Srgb) + fn chain_at(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail { + graph.compose_detail_for(scale, ColourSpace::Srgb) } #[test] @@ -552,7 +561,7 @@ mod tests { // The rule the whole pipeline rests on: an operation at its defaults // costs nothing. Almost every photograph in a library is unsharpened, // and none of them should pay a dispatch for it. - let composed = chain_at(&crate::ops::chain(), RenderScale::full((512, 512))); + let composed = chain_at(&EditGraph::default_chain(), RenderScale::full((512, 512))); assert!(composed.is_empty()); assert_eq!(composed.radius(), 0); } @@ -636,53 +645,59 @@ mod tests { #[test] fn the_radius_is_a_sensor_length_not_a_viewport_one() { // TRACES: FR-DSP-1 — the decision this operation is most likely to get - // wrong, asserted directly. + // wrong, asserted directly. It is the test the brief asked for: the + // same edit at more than one render resolution has to sharpen the same + // photograph. // - // The same edit, composed at three resolutions of one 4000-pixel - // frame. A radius in source pixels must come out as the same number of - // *source* pixels every time, which means a different number of render - // pixels every time. Read the stored radius as render pixels instead - // and the third assertion below is the one that fails: the kernel - // would be four sigma wide at every size, so the proxy on screen would - // be sharpened four times as hard, relative to the picture, as the file - // that gets exported. - let ops = sharpening(80.0, 4.0); + // Three renders of one 4000-pixel frame: an export, a half-size proxy, + // and a 40% fit view. A radius in source pixels must come out as the + // same number of *source* pixels every time, which means a different + // number of render pixels every time. Read the stored radius as render + // pixels instead and the last three assertions are the ones that fail: + // the kernel would be three sigma wide at every size, so the proxy on + // screen would be sharpened two and a half times as hard, relative to + // the picture, as the file that gets exported. + // + // The radius is the slider's maximum rather than a round number past + // it, because `EditGraph::set_param` clamps and a test asserting on a + // radius the graph would have clipped would be asserting about a + // photograph nobody can produce. + let graph = sharpening(80.0, 3.0); let full = (4000u32, 4000u32); let in_source_pixels = |render: u32| -> f32 { let scale = RenderScale::new((render, render), full); - chain_at(&ops, scale).radius() as f32 / scale.ratio() + chain_at(&graph, scale).radius() as f32 / scale.ratio() }; - // Export, half-size proxy, quarter-size proxy. let export = in_source_pixels(4000); let half = in_source_pixels(2000); - let quarter = in_source_pixels(1000); + let fit = in_source_pixels(1600); - assert!((export - 12.0).abs() < 0.01, "three sigma of four pixels"); + assert!((export - 9.0).abs() < 0.01, "three sigma of three pixels"); // Within the rounding of one render pixel back through the ratio, // which is the whole of the permitted error: the kernel is an integer - // count of render pixels and 12 does not divide evenly by four. + // count of render pixels, and one of those is two source pixels on the + // half proxy and two and a half on the fit view. assert!( (half - export).abs() <= 2.0, "the same edit covers {half} source pixels on a half proxy and \ {export} at export" ); assert!( - (quarter - export).abs() <= 4.0, - "the same edit covers {quarter} source pixels on a quarter proxy \ - and {export} at export" + (fit - export).abs() <= 2.5, + "the same edit covers {fit} source pixels on a 40% view and \ + {export} at export" ); // The other half of the statement, and the one that fails if the unit // is misread: the kernel in *render* pixels must shrink with the // render, because that is what keeps it the same size on the picture. - let render_pixels = |render: u32| { - chain_at(&ops, RenderScale::new((render, render), full)).radius() - }; - assert_eq!(render_pixels(4000), 12); - assert_eq!(render_pixels(2000), 6); - assert_eq!(render_pixels(1000), 3); + let render_pixels = + |render: u32| chain_at(&graph, RenderScale::new((render, render), full)).radius(); + assert_eq!(render_pixels(4000), 9); + assert_eq!(render_pixels(2000), 5, "ceil(3 sigma of 1.5)"); + assert_eq!(render_pixels(1600), 4, "ceil(3 sigma of 1.2)"); } #[test] @@ -692,11 +707,11 @@ mod tests { // on went out with the downscale. `RenderScale::resolves` reports the // condition and this obeys it, because a preview that shows sharpening // the exported file will not contain is worse than one that shows none. - let ops = sharpening(100.0, 1.0); + let graph = sharpening(100.0, 1.0); let proxy = RenderScale::new((1000, 1000), (4000, 4000)); assert!(!proxy.resolves(1.0)); - let composed = chain_at(&ops, proxy); + let composed = chain_at(&graph, proxy); // Not empty, though. See `nothing_to_sharpen`: the fused pass has // already been composed to hand on linear values, so *something* must // still perform the output transform. @@ -715,7 +730,7 @@ mod tests { // the render target keeps its size — so there is no separate // full-resolution preview path for a photographer to wait on. let one_to_one = RenderScale::new((1000, 1000), (1000, 1000)); - assert_eq!(chain_at(&ops, one_to_one).len(), 2); + assert_eq!(chain_at(&graph, one_to_one).len(), 2); } #[test] @@ -742,13 +757,9 @@ mod tests { // being positive, so a photographer who never touches the threshold // gets the plain unsharp mask and not a NaN. let gate_of = |threshold: f32| -> f32 { - let mut ops = sharpening(50.0, 1.0); - for op in &mut ops { - if op.descriptor().id == ID { - op.set_param(THRESHOLD, threshold); - } - } - let composed = chain_at(&ops, RenderScale::full((512, 512))); + let mut graph = sharpening(50.0, 1.0); + graph.set_param(ID, THRESHOLD, threshold); + let composed = chain_at(&graph, RenderScale::full((512, 512))); *composed.passes[0].uniforms.last().expect("a gate") }; assert_eq!(gate_of(0.0), 0.0); @@ -777,6 +788,51 @@ mod tests { } } + #[test] + fn the_kernel_declares_no_wgsl_reserved_keyword() { + // `lib.rs` runs this check over the fused fragments and cannot reach + // here: a detail pass is a separate shader, composed at a resolution + // that `compose()` never sees. It is worth repeating rather than + // skipping, because the failure it catches is the least legible one in + // this crate — `let target = ...` in the contrast fragment once failed + // with "name `target` is a reserved keyword", pointing at generated + // source rather than at the operation that wrote it, and this body + // reaches for exactly that word. + // + // Not the full reserved list; the words a convolution would plausibly + // pick for a local. + // The same list `no_fragment_declares_a_wgsl_reserved_keyword` uses, + // kept identical on purpose: two lists that drift apart would let a + // word be safe in one stage and not in the other, which is the sort of + // difference nobody discovers until a shader fails to compile. + const RESERVED: &[&str] = &[ + "target", "sample", "filter", "texture", "buffer", "binding", "const", "enum", "mat", + "vec", "ptr", "ref", "shared", "static", "typedef", "union", "unless", "handle", + "layout", "packed", "premerge", "regardless", "active", "do", "input", "output", + "private", "resource", "restrict", "self", "std", "where", + ]; + + for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes { + // Only what this operation wrote. The composer's own preamble + // declares `var output` and `let coord`, which naga accepts and + // which are not this test's business. + let body = pass + .source + .rsplit_once(" {\n") + .expect("the operation's block") + .1; + for keyword in RESERVED { + for form in [format!("let {keyword} "), format!("var {keyword} ")] { + assert!( + !body.contains(&form), + "{} declares `{keyword}`, which is a WGSL reserved keyword", + pass.label + ); + } + } + } + } + #[test] fn a_deep_zoom_cannot_turn_a_slider_into_an_unbounded_convolution() { // Zooming past about 16:1 pushes the ratio above one, and a radius in diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 8820974..4ba5b2a 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -1055,11 +1055,20 @@ impl DevelopSession { /// The mask array is rasterised in source space at proxy size and sampled /// through the framing map, so one array is correct at every output size: /// a 256px thumbnail and a 24 MP export bind the same texture. + /// + /// `space` is the output space `shader` was composed for, and it has to be + /// passed rather than assumed because the **detail stage** is composed + /// here too and the two halves must agree. When an edit has an active + /// neighbourhood operation the fused pass stops at unclipped linear + /// working values and the last detail pass performs the output transform; + /// composing the fused half for Display P3 and the detail half for sRGB + /// would encode the export in the wrong space, with nothing to notice it. fn render_with_masks( &mut self, shader: &dr_pipeline::operation::ComposedShader, w: u32, h: u32, + space: dr_types::ColourSpace, ) -> Result<(), String> { let ctx = self.ctx.clone(); self.ensure_subject_fields(&ctx); @@ -1069,8 +1078,29 @@ impl DevelopSession { .then(|| self.masks.as_ref().and_then(|p| p.array())) .flatten(); + // The neighbourhood stage, composed at the size actually being drawn. + // + // It has to be composed *per render* rather than cached with the edit, + // because a kernel is the one thing in this pipeline that is not + // scale-free: a sharpening radius is stated in source pixels and the + // develop view renders at whatever the viewport needs (FR-DSP-1), so + // the conversion is different for the canvas, the thumbnail and the + // export. `render_scale` works the ratio out from the framing, which + // is also what makes zooming to 1:1 restore an exact preview with no + // second render path to maintain. + // + // Empty for every edit with no active neighbourhood operation — which + // is almost all of them — and `render_detailed` then falls straight + // through to the single masked dispatch this used to call. + let scale = self.graph.render_scale(self.demosaiced.size(), (w, h)); + let detail = self.graph.compose_detail_for(scale, space); + let colour_key = self + .graph + .invalidation() + .through(dr_pipeline::Affects::Colour); + self.adjust - .render_masked(&self.demosaiced, shader, w, h, masks) + .render_detailed(&self.demosaiced, shader, w, h, masks, &detail, colour_key) .map(|_| ()) .map_err(|e| e.to_string()) } @@ -1769,7 +1799,7 @@ impl DevelopSession { // Rasterise the masks first: the shader addresses array slices by // index, so the array has to describe *this* stack before it is bound. - self.render_with_masks(&shader, w, h)?; + self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?; let texture = self.adjust.output().ok_or("nothing was rendered")?; // The import is fallible on format and usage only, and both are fixed @@ -1893,7 +1923,7 @@ impl DevelopSession { let (w, h) = self.graph.output_size(sw, sh); let shader = self.graph.compose_for(space); - self.render_with_masks(&shader, w, h)?; + self.render_with_masks(&shader, w, h, space)?; let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?; dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string()) @@ -1920,7 +1950,7 @@ impl DevelopSession { let (w, h) = fit(fw, fh, edge.max(1), edge.max(1)); let shader = self.graph.compose_for(dr_types::ColourSpace::Srgb); - self.render_with_masks(&shader, w, h)?; + self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?; let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?; Ok((rw, rh, pixels)) From 00663a870b091ff9407e6cfc03c37d0fa989d94a Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:14:54 +0200 Subject: [PATCH 12/27] Teach the chain test that a detail node has no fused fragment every_operation_can_be_activated_together counted one block per operation in the chain, which was true only while every operation was a point function. A neighbourhood operation is a dispatch of its own and emits no fused block, so the count now excludes the operations the detail chain names, and each of them is separately asserted absent rather than the comparison being loosened. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-pipeline/src/lib.rs | 40 +++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index a763ef9..68545e9 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -107,13 +107,45 @@ mod tests { let g = fully_active(); assert!(!g.is_neutral()); let shader = g.compose(); - // Counted against the chain rather than a literal, so adding an - // operation does not require editing this test. + + // A neighbourhood operation contributes no fused fragment. That is not + // an omission: it reads pixels it is not writing, the fused contract + // hands a fragment a colour with no way back to a coordinate, and + // `compose_full` filters it out rather than emitting an empty block + // that would read as an operation doing nothing (see `crate::detail`). + // + // So the count is against the operations that *can* be fused, and the + // ones that cannot are named by the detail chain rather than by a list + // written here — which is what keeps this test correct as sharpening, + // noise reduction and clarity arrive, rather than weakening it into + // "most of them appear". + // + // Composed at source resolution deliberately: an acutance operation's + // radius is in source pixels, and on a proxy it may honestly decline + // to draw at all (`RenderScale::resolves`), which would leave it out + // of both halves and make this count agree for the wrong reason. + let detail = g.compose_detail(crate::detail::RenderScale::full((4096, 4096))); + let neighbourhood: std::collections::BTreeSet<&str> = detail + .passes + .iter() + .map(|p| p.label.split('/').next().expect("/")) + .collect(); + assert_eq!( shader.source.matches("---- ").count(), - g.descriptors().len(), - "every operation in the chain should appear" + g.descriptors().len() - neighbourhood.len(), + "every operation that can be a fused fragment should appear" ); + + // And each neighbourhood operation is genuinely absent from the fused + // shader rather than merely uncounted — the arithmetic above would be + // satisfied just as well by two errors that cancelled. + for id in &neighbourhood { + assert!( + !shader.source.contains(&format!("---- {id} ----")), + "{id} reads its neighbours and cannot be a fused fragment" + ); + } } #[test] From df3fe660e582bb07ecc877ca1c028cd9176704d1 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:14:53 +0200 Subject: [PATCH 13/27] Finish the render when a kernel is too small to draw An active detail operation may emit no pass at a given render scale - the honest answer for a sensor-sized radius on a heavy proxy. The fused composer cannot see that, having no resolution to consult, so it had already stopped short of the output transform and the frame died on a storage-format mismatch. Compose a bodyless resolve pass in that case so the output transform still happens exactly once. --- core/dr-pipeline/src/detail.rs | 93 ++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/core/dr-pipeline/src/detail.rs b/core/dr-pipeline/src/detail.rs index cccc909..6226a8d 100644 --- a/core/dr-pipeline/src/detail.rs +++ b/core/dr-pipeline/src/detail.rs @@ -449,6 +449,48 @@ pub fn compose_detail( } } + // An active detail operation that emitted nothing at this scale. + // + // Legal, and the honest answer for an acutance operation on a heavy proxy + // — a one-source-pixel radius is a third of a render pixel there and no + // kernel represents a third of a pixel (see [`RenderScale`]). But it opens + // a hole between the two halves of the composition: [`compose_full`] + // decides to hand on linear working values from the *operations*, which it + // must, having no scale to consult, so the fused pass has already stopped + // short of the output transform. Returning an empty chain here would leave + // that transform undone and bind an `rgba16float` shader to an + // `rgba8unorm` target, which surfaces as a wgpu validation failure a long + // way from the cause. + // + // So the chain is never empty when the fused pass is expecting one: a + // single pass with no body, which reads the intermediate and performs the + // output transform the fused pass skipped. One dispatch, in the uncommon + // case where a photographer has a kernel switched on at a scale that + // cannot draw it — against the alternative of the preview failing outright + // or `compose_full` growing a resolution argument it has no other use for. + if planned.is_empty() + && ops + .iter() + .any(|o| o.is_active() && o.detail().is_some()) + { + return ComposedDetail { + passes: vec![compose_one( + RESOLVE_ID, + &[], + &DetailPass { + label: "resolve", + radius: 0, + wgsl: String::new(), + uniforms: Vec::new(), + }, + 0, + scale, + output, + true, + )], + }; + } + let last = planned.len().saturating_sub(1); let passes = planned .into_iter() @@ -461,6 +503,14 @@ pub fn compose_detail( ComposedDetail { passes } } +/// The operation id the resolve pass is labelled with. +/// +/// Not an operation: no `ops/*.yaml` declares it and nothing in the chain +/// answers to it. It exists so the generated label reads `detail/resolve` +/// rather than borrowing the id of whichever operation happened to fall +/// through, which would send a reader looking for a bug in that operation. +const RESOLVE_ID: &str = "detail"; + #[allow(clippy::too_many_arguments)] fn compose_one( id: &str, @@ -880,6 +930,49 @@ mod tests { } } + #[test] + fn an_active_operation_that_draws_nothing_still_finishes_the_render() { + // The seam between the two composers, and the one case where they + // cannot see each other. `compose_full` decides to hand on linear + // working values from the *operations* — it has no resolution to + // consult — while this composer converts a radius and can legitimately + // decide there is nothing to draw at this size. An empty chain would + // then leave the output transform undone: the fused pass writes + // `rgba16float` and the frontend binds an `rgba8unorm` target to it. + // + // A photographer meets this by turning on capture sharpening or + // luminance noise reduction while the develop view is fitted to a + // large file, which is the normal way to work, so it is not an edge + // case that can be left to fail. + let ops = with_blur(0.001); + let scale = RenderScale::full((400, 400)); + assert!(ops.last().expect("the blur").is_active()); + assert_eq!( + BoxBlur::with_radius(0.001).passes(scale).len(), + 0, + "the premise: a radius too small to draw emits no pass" + ); + + let composed = compose_detail(&ops, scale, dr_types::ColourSpace::Srgb); + assert_eq!(composed.len(), 1, "the chain must not be empty here"); + assert_eq!(composed.radius(), 0, "it reads only the pixel it writes"); + + let resolve = &composed.passes[0]; + assert_eq!(resolve.label, "detail/resolve"); + assert!(resolve.writes_output); + assert!(resolve.source.contains("texture_storage_2d Date: Sat, 22 Aug 2026 19:15:06 +0200 Subject: [PATCH 14/27] Let the copyright survive the export, and the GPS not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the source's metadata all the way to the file the user hands over, and proves in bytes that the coordinates do not come with it. The privacy test was the piece that mattered and the piece that was wrong. It searched the whole file for the two-byte hemisphere reference "N\0" or "E\0", which is not a fingerprint of a GPS directory at all: sample 14 of the sRGB tone curve inside the ICC profile every export embeds is 69, written as `00 45`, and the next sample is below 256, so its high byte is `00`. Every format would have failed a test about a colour profile. The needle is now the twenty-four bytes a coordinate actually serialises to — three rationals, both byte orders, since exif.rs writes little-endian and the tiff crate writes in the host's — which cannot match by accident, and the retaining test asserts the same needle is *present* so a search that could never find anything cannot make the stripping test pass by being useless. The batch exporter now hands the decoder's reading on to the encoder. It already read the metadata for the orientation and the {date} token; passing it through is what puts the camera, the lens and the rights statement into the file. Nothing about privacy is decided there — dr-export takes that decision once, from the settings. The example passes it too, because it is the only place in the tree that produces files a person can open in exiftool. A unit test can prove a GPS directory is absent from a byte slice; only a real export proves a real photograph comes out the far end still knowing which camera took it. TRACES: FR-EXP-8 Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-export/examples/export.rs | 30 ++++++++++++- core/dr-export/src/encode.rs | 69 +++++++++++++++++++++++++--- ui/dr-ui/src/export.rs | 74 ++++++++++++++++++++++++++++--- 3 files changed, 158 insertions(+), 15 deletions(-) diff --git a/core/dr-export/examples/export.rs b/core/dr-export/examples/export.rs index 9612aa6..1aeb3b2 100644 --- a/core/dr-export/examples/export.rs +++ b/core/dr-export/examples/export.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; -use dr_export::{export, Frame, NameContext}; +use dr_export::{export, Frame, NameContext, SourceMetadata}; use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext}; use dr_pipeline::EditGraph; use dr_types::{ColourSpace, ExportFormat, ExportSettings, OutputSharpening, SizingMode}; @@ -96,6 +96,32 @@ fn main() { .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "export".into()); + // TRACES: FR-EXP-8 + // What the input said about itself, transcribed field by field into the + // allowlist `dr-export` will write from. The example passes it because + // this is the one place in the tree that produces files a person can open + // in exiftool — a unit test can prove a GPS directory is absent from a + // byte slice, but only a real export proves that a real photograph comes + // out of the far end still knowing which camera took it. + // + // The defaults apply, so the files written here carry the camera, the + // lens, the exposure and the rights statement, and carry no coordinates. + let meta = dr_decode::metadata(&bytes).unwrap_or_default(); + let source_metadata = SourceMetadata { + make: meta.make.clone(), + model: meta.model.clone(), + lens: meta.lens.clone(), + shutter: meta.shutter, + aperture: meta.aperture, + iso: meta.iso, + focal_length: meta.focal_length, + captured_at: meta.captured_at, + captured_offset: meta.captured_offset, + artist: meta.artist.clone(), + copyright: meta.copyright.clone(), + location: meta.location, + }; + // One of each format, so the run exercises every encoder that exists. for (format, sizing, sharpening) in [ ( @@ -155,7 +181,7 @@ fn main() { .expect("a free name"); let t = std::time::Instant::now(); - let out = export(&frame, &settings, name).expect("export"); + let out = export(&frame, &settings, name, Some(&source_metadata)).expect("export"); let path = out_dir.join(&out.name); std::fs::write(&path, &out.bytes).expect("write"); println!( diff --git a/core/dr-export/src/encode.rs b/core/dr-export/src/encode.rs index 01f96d2..9535a72 100644 --- a/core/dr-export/src/encode.rs +++ b/core/dr-export/src/encode.rs @@ -742,7 +742,7 @@ mod tests { artist: Some("Duncan Tourolle".into()), copyright: Some("(c) 2026 Duncan Tourolle".into()), // 48° 51' 29.52" N, 2° 17' 40.2" E. - location: dr_types::Location::new(48.8582, 2.2945, Some(35.0)), + location: dr_types::Location::new(LATITUDE, LONGITUDE, Some(35.0)), } } @@ -802,6 +802,45 @@ mod tests { bytes.windows(4).any(|w| w == LITTLE || w == BIG) } + /// TRACES: FR-EXP-8 + /// Whether the source's own coordinates appear anywhere in the bytes. + /// + /// The complement of [`has_gps_pointer`]. That one says no reader has a + /// *route* to a position; this says the numbers themselves are not in the + /// file at all — not under some other tag, not in a directory this test + /// did not think to look in, not left behind in a heap after the entry + /// pointing at it was dropped. + /// + /// The needle is the twenty-four bytes a coordinate serialises to: three + /// rationals, degrees, minutes and seconds. Specific enough that a match + /// is the coordinate rather than a coincidence, which matters because the + /// obvious cheaper needle is not: searching for the hemisphere letter as + /// `"N\0"` or `"E\0"` matches the tone curve inside the ICC profile every + /// export carries — sample 14 of the sRGB curve is 69, which is `00 45`, + /// beside a sample below 256, which is `00 xx`. A privacy test that fails + /// on the colour profile teaches nobody anything. + /// + /// Both byte orders, because `exif.rs` writes little-endian and the `tiff` + /// crate writes in the host's. + fn contains_coordinate(bytes: &[u8], degrees: f64) -> bool { + let mut little = Vec::new(); + let mut big = Vec::new(); + for (n, d) in exif::dms(degrees) { + little.extend_from_slice(&n.to_le_bytes()); + little.extend_from_slice(&d.to_le_bytes()); + big.extend_from_slice(&n.to_be_bytes()); + big.extend_from_slice(&d.to_be_bytes()); + } + bytes + .windows(little.len()) + .any(|w| w == little.as_slice() || w == big.as_slice()) + } + + /// The latitude and longitude [`source`] carries, for the two tests that + /// look for them in the bytes. + const LATITUDE: f64 = 48.8582; + const LONGITUDE: f64 = 2.2945; + #[test] fn no_export_carries_a_location_by_default() { // TRACES: FR-EXP-8 @@ -818,11 +857,16 @@ mod tests { ); let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); assert_eq!(md.location, None, "{format:?} decodes to a position"); - // And the coordinates are not loose in the file under some other - // tag: the hemisphere letters a GPS directory always carries. + // And the numbers are not loose in the file with nothing pointing + // at them, which is what a scrubber that unlinked the directory + // without dropping its values would leave behind. assert!( - !bytes.windows(2).any(|w| w == b"N\0" || w == b"E\0"), - "{format:?} contains a hemisphere reference" + !contains_coordinate(&bytes, LATITUDE), + "{format:?} still contains the latitude" + ); + assert!( + !contains_coordinate(&bytes, LONGITUDE), + "{format:?} still contains the longitude" ); } } @@ -863,6 +907,17 @@ mod tests { has_gps_pointer(&bytes), "{format:?} dropped the position it was asked to keep" ); + // The control for `contains_coordinate` as well as for the + // pointer: a search that could never find the numbers would make + // the stripping test above pass without proving anything. + assert!( + contains_coordinate(&bytes, LATITUDE), + "{format:?} carries no latitude for the strip test to be about" + ); + assert!( + contains_coordinate(&bytes, LONGITUDE), + "{format:?} carries no longitude for the strip test to be about" + ); let md = read_back(format, &bytes).unwrap_or_else(|| panic!("{format:?} has no EXIF")); assert_eq!(md.make.as_deref(), Some("Canon"), "{format:?}"); assert_eq!(md.model.as_deref(), Some("Canon EOS 6D"), "{format:?}"); @@ -883,8 +938,8 @@ mod tests { let loc = md.location.unwrap_or_else(|| panic!("{format:?} lost the fix")); // Within a metre of where it started, which is finer than any // consumer receiver and far finer than the tag's own rounding. - assert!((loc.latitude - 48.8582).abs() < 1e-5, "{format:?} {loc:?}"); - assert!((loc.longitude - 2.2945).abs() < 1e-5, "{format:?} {loc:?}"); + assert!((loc.latitude - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}"); + assert!((loc.longitude - LONGITUDE).abs() < 1e-5, "{format:?} {loc:?}"); assert_eq!(loc.altitude, Some(35.0), "{format:?}"); } } diff --git a/ui/dr-ui/src/export.rs b/ui/dr-ui/src/export.rs index a3e6553..2a5be24 100644 --- a/ui/dr-ui/src/export.rs +++ b/ui/dr-ui/src/export.rs @@ -617,8 +617,15 @@ fn export_one( issued: &mut HashSet, cancel: &Cancel, ) -> Option> { - let (stem, date, frame) = match source { - Source::Rendered { stem, frame } => (stem, String::new(), frame), + // TRACES: FR-EXP-8 + // The fourth element is what the photograph's own file said about itself. + // A library image is decoded here, so it has one; a frame handed over + // already rendered does not — the develop session holds pixels and an edit + // graph, not the header they came from, so an export from the develop + // button carries only what `dr-export` writes about itself until that is + // plumbed through the session. + let (stem, date, frame, source_metadata) = match source { + Source::Rendered { stem, frame } => (stem, String::new(), frame, None), Source::Library { path, cache } => { match render_from_library(request, &path, cache, cancel)? { Ok(rendered) => rendered, @@ -627,7 +634,42 @@ fn export_one( } }; - Some(place_frame(request, &stem, &date, sequence, &frame, issued)) + Some(place_frame( + request, + &stem, + &date, + sequence, + &frame, + source_metadata.as_ref(), + issued, + )) +} + +/// TRACES: FR-EXP-8 +/// What an export is allowed to carry from the file it was decoded from. +/// +/// Field by field rather than a conversion trait, and that is the point: +/// `dr_export::SourceMetadata` is an allowlist, so a tag newly parsed by +/// `dr-decode` reaches an exported file only when somebody adds a line here +/// and thereby decides, in writing, that it may leave the machine. The +/// location travels — `dr-export` is where the stripping decision is taken, +/// once, from the settings, and duplicating it here would give two places to +/// disagree. +fn carried_metadata(meta: &dr_decode::Metadata) -> dr_export::SourceMetadata { + dr_export::SourceMetadata { + make: meta.make.clone(), + model: meta.model.clone(), + lens: meta.lens.clone(), + shutter: meta.shutter, + aperture: meta.aperture, + iso: meta.iso, + focal_length: meta.focal_length, + captured_at: meta.captured_at, + captured_offset: meta.captured_offset, + artist: meta.artist.clone(), + copyright: meta.copyright.clone(), + location: meta.location, + } } /// Fetch a photograph, apply its stored edit, and render it at full size. @@ -636,7 +678,17 @@ fn render_from_library( path: &str, cache: Option, cancel: &Cancel, -) -> Option> { +) -> Option< + Result< + ( + String, + String, + dr_export::Frame, + Option, + ), + ItemError, + >, +> { let Some((creds, user_id)) = request.creds.clone() else { return Some(Err(ItemError::Fetch("no library is open".into()))); }; @@ -721,7 +773,12 @@ fn render_from_library( .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "export".into()); - Some(Ok((stem, date, frame))) + // TRACES: FR-EXP-8 + // `meta` was read at the top of this function for the orientation and the + // `{date}` token; carrying it on to the encoder is what puts the camera, + // the lens and the rights statement into the exported file. What is + // *dropped* from it is decided in `dr-export` from the settings, not here. + Some(Ok((stem, date, frame, Some(carried_metadata(&meta))))) } /// Name, encode and write one rendered frame. @@ -733,6 +790,11 @@ fn place_frame( date: &str, sequence: u32, frame: &dr_export::Frame, + // TRACES: FR-EXP-8 + // What the source file said about itself, or `None` where the caller has + // nothing to say. Handed straight through: every decision about what of it + // reaches the file is taken inside `dr-export`, from the settings. + source: Option<&dr_export::SourceMetadata>, issued: &mut HashSet, ) -> Result { // The size is resolved before the name because `{dimensions}` is one of the @@ -754,7 +816,7 @@ fn place_frame( }; let name = resolve_batch_name(&request.settings, &ctx, issued).ok_or(ItemError::NameTaken)?; - let encoded = dr_export::export(frame, &request.settings, name)?; + let encoded = dr_export::export(frame, &request.settings, name, source)?; place( &encoded, From 69fb510c50169367ab4b256ed36c5c015e654f1f Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:16:43 +0200 Subject: [PATCH 15/27] Measure the chroma tests on colour difference, not on red A colour square wave built from equal, opposite swings of red and blue is not a pure colour pattern: Rec. 709 weights them 0.2126 and 0.0722, so it carries a luminance square wave of about a seventh of the swing underneath. Correlating the raw red channel therefore reads a constant floor that the chroma filter is not meant to remove, which compressed every ratio towards one - enough that the resolution test could no longer tell a correct kernel from one twice the size. Correlate the colour difference instead, and write the derivation of each expected value into the test. --- core/dr-gpu/tests/noise_reduction.rs | 146 ++++++++++++++++++++------- 1 file changed, 112 insertions(+), 34 deletions(-) diff --git a/core/dr-gpu/tests/noise_reduction.rs b/core/dr-gpu/tests/noise_reduction.rs index 92ba008..8ebf2f3 100644 --- a/core/dr-gpu/tests/noise_reduction.rs +++ b/core/dr-gpu/tests/noise_reduction.rs @@ -161,16 +161,28 @@ fn a_difference_below_the_threshold_is_averaged_and_one_above_it_is_not() { }; // A step of eight code values around mid-grey is about 0.028 in linear - // luminance, against a threshold of roughly 0.034 at full amount: within + // luminance, against a threshold of roughly 0.035 at full amount: within // the range where the filter is meant to treat a difference as noise. + // + // Worked out on paper, since it cannot be run here: at amount 100 the + // kernel is 3 render pixels and sigma_k is 0.075, so at y = 0.2159 the + // range sigma is 0.075·sqrt(0.2159 + 0.0025) = 0.0350 and a neighbour + // 0.0280 away is weighted exp(-0.320) = 0.726. Summing the 7×7 kernel's + // spatial weights over the four bright columns and the three dark ones + // gives a filtered luminance of 0.2076 against 0.2159 — a move of 0.0082, + // which survives the 8-bit readback as about **0.0072**. The threshold + // below is set well under that rather than at it: what would be a bug is + // the filter declining to average at all. let quiet = measure(&step_edge(&ctx, SIZE, 120, 128)); assert!( quiet > 0.004, "a difference below the threshold was left alone: moved {quiet}" ); - // Black to white is thirty times the threshold. It has to survive intact - // — an edge that softens here is a halo in every high-contrast picture. + // Black to white is thirteen times the threshold — 1.0 against a sigma of + // 0.075·sqrt(1.0025) = 0.0751 — so a neighbour across it is weighted + // exp(-88), which is zero in any arithmetic. The edge has to survive + // intact; one that softens here is a halo in every high-contrast picture. let loud = measure(&step_edge(&ctx, SIZE, 0, 255)); assert!( loud.abs() < 0.004, @@ -182,12 +194,18 @@ fn a_difference_below_the_threshold_is_averaged_and_one_above_it_is_not() { ); } -/// A fine chroma pattern on a constant grey: colour that alternates every -/// `half_period` pixels with the lightness very nearly fixed. +/// A fine chroma pattern: red and blue swung in opposite directions by the +/// same number of code values, green held. /// /// This is what chroma noise looks like to the filter — a colour difference -/// with almost no luminance difference under it — and it is the one pattern -/// that can tell the two halves of this operation apart. +/// alternating over a few pixels — and it is the one pattern that can tell the +/// two halves of this operation apart. +/// +/// It is not a *pure* colour pattern, and the tests must not assume it is. +/// Rec. 709 weights red at 0.2126 and blue at 0.0722, so swinging one up and +/// the other down by equal amounts moves lightness by about a seventh of the +/// swing. That residue is real, it is not the chroma filter's to remove, and +/// [`chroma_r`] is what keeps it out of the measurements. fn chroma_pattern(ctx: &GpuContext, size: u32, half_period: u32, swing: i32) -> DemosaicedImage { upload(ctx, size, move |x, _| { let on = (x / half_period) % 2 == 0; @@ -196,8 +214,8 @@ fn chroma_pattern(ctx: &GpuContext, size: u32, half_period: u32, swing: i32) -> }) } -/// How much of a known alternating pattern survived, as the correlation of one -/// linear channel of the middle row against the pattern's own sign. +/// How much of a known alternating pattern survived, as the correlation of a +/// chosen measurement of the middle row against the pattern's own sign. /// /// A matched filter rather than a peak-to-peak reading. The readback is eight /// bits, and the modulation these tests work with is only a handful of code @@ -209,25 +227,39 @@ fn chroma_pattern(ctx: &GpuContext, size: u32, half_period: u32, swing: i32) -> /// `margin` covers a whole number of periods too, so the window is unbiased by /// the row's mean, and it keeps the measurement clear of the borders where a /// clamped kernel legitimately behaves differently. -fn modulation(pixels: &[u8], width: u32, half_period: u32, channel: usize) -> f32 { +/// +/// `sample` is what to measure. Passing [`chroma_r`] rather than the raw red +/// channel matters more than it looks: a colour square wave built from 8-bit +/// code values carries a *luminance* square wave under it — Rec. 709 does not +/// weight red and blue equally, so swinging one up and the other down moves +/// lightness too — and that component is not the chroma filter's to remove. +/// Left in the measurement it is a constant floor under every reading, which +/// compresses every ratio this file asserts towards one and would leave the +/// tests unable to tell a correct kernel from one twice the size. +fn modulation( + pixels: &[u8], + width: u32, + half_period: u32, + sample: impl Fn([f32; 3]) -> f32, +) -> f32 { let row = width / 2; let margin = half_period * 4; let mut total = 0.0; let mut count = 0.0; for x in margin..(width - margin) { - let sample = match channel { - usize::MAX => luminance(linear(pixels, width, x, row)), - c => linear(pixels, width, x, row)[c], - }; + let value = sample(linear(pixels, width, x, row)); let sign = if (x / half_period) % 2 == 0 { 1.0 } else { -1.0 }; - total += sample * sign; + total += value * sign; count += 1.0; } total / count } -/// Correlate against luminance rather than a channel. -const AS_LUMINANCE: usize = usize::MAX; +/// The red component of the colour difference — red with its lightness taken +/// out, which is the quantity the chroma passes actually filter. +fn chroma_r(c: [f32; 3]) -> f32 { + c[0] - luminance(c) +} #[test] fn chroma_noise_reduction_never_moves_lightness() { @@ -255,26 +287,36 @@ fn chroma_noise_reduction_never_moves_lightness() { // And on an image that does have colour to filter, where the two halves // could actually interfere: the colour modulation must fall while the - // luminance modulation under it stays where it was. The tolerance is wide - // because both readings pass through an eight-bit readback twice over; the - // failure it guards against is not a drift of a few percent but a - // collapse, which is what a leak between the two components would be. + // luminance modulation under it stays where it was. + // + // On paper, at amount 100 over a half-period of 4: the kernel is 12 render + // pixels, both chroma thresholds are 0.16·sqrt(y + floor) ≈ 0.076 and + // 0.20·… ≈ 0.095, so an opposite-coloured neighbour is weighted 0.410, and + // summing the separable kernel over the eight phases leaves about **0.42** + // of the chroma amplitude — 0.0158 of 0.0377. The luminance amplitude must + // not move at all: every colour difference the pass averages has zero + // luminance by construction, so their weighted mean does too. + // + // Both tolerances are wide of those numbers because both readings pass + // through an eight-bit readback twice over; the failure they guard against + // is not a drift of a few percent but a collapse, which is what a leak + // between the two components would be. let source = chroma_pattern(&ctx, SIZE, 4, 12); let mut off = AdjustPass::new(&ctx); let plain = render(&ctx, &mut off, &graph_with(0.0, 0.0), &source, (SIZE, SIZE)); let mut on = AdjustPass::new(&ctx); let chroma = render(&ctx, &mut on, &graph_with(0.0, 100.0), &source, (SIZE, SIZE)); - let colour_before = modulation(&plain, SIZE, 4, 0); - let colour_after = modulation(&chroma, SIZE, 4, 0); + let colour_before = modulation(&plain, SIZE, 4, chroma_r); + let colour_after = modulation(&chroma, SIZE, 4, chroma_r); assert!( colour_after < colour_before * 0.7, "chroma noise reduction did not reduce the colour swing: \ {colour_after} of {colour_before}" ); - let light_before = modulation(&plain, SIZE, 4, AS_LUMINANCE); - let light_after = modulation(&chroma, SIZE, 4, AS_LUMINANCE); + let light_before = modulation(&plain, SIZE, 4, luminance); + let light_after = modulation(&chroma, SIZE, 4, luminance); assert!( (light_after - light_before).abs() < light_before.abs() * 0.3, "chroma noise reduction moved lightness: {light_after} was {light_before}" @@ -330,8 +372,44 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() { // // The subject is a chroma square wave with a period that is a power of two // and aligned to the frame, so halving the render resolution decimates it - // exactly — the proxy sees the same pattern at half the period, with no - // resampling of its own to confuse the measurement. + // exactly. The fused pass loads the nearest source pixel when the framing + // is unrotated, so output column `x` reads source column `2x` — always the + // same half of an eight-wide block as `2x + 1` — and the proxy sees the + // same two colours at half the period, with no resampling of its own to + // confuse the measurement. + // + // # The expected numbers + // + // Worked out on paper, because the tolerances below are meaningless + // without knowing what they are tolerances *around*. + // + // The two colours are (134, 128, 122) and (122, 128, 134), which decode to + // linear (0.2384, 0.2159, 0.1946) and its mirror. Their colour differences + // are ±(0.0193, -0.0033, -0.0245), so the pattern's chroma amplitude — + // what `modulation` with `chroma_r` reads — is 0.0188 before filtering. + // + // At amount 60 both chroma thresholds are 0.12·sqrt(y + floor) ≈ 0.0565, + // so a neighbour of the opposite colour is weighted + // `exp(-(dl²/2σ_g² + |dc|²/2σ_c²)) ≈ exp(-0.624) ≈ 0.536`: attenuated, but + // far from rejected, which is the regime where the kernel's *width* is + // what decides the answer. That is the point — a test where the range + // weights dominated would pass whatever the radius conversion did. + // + // Summing the separable kernel's spatial weights over the eight phases of + // the pattern gives a mean surviving fraction of **0.521** at export (a + // radius of 8 render pixels over a half-period of 8) and **0.521** on the + // proxy (4 over 4) — the two arrangements are the same filter sampled at + // two rates, and they agree to three decimal places. So both amplitudes + // land at about 0.0098. + // + // The control lands at **0.305**, about 0.0057: a kernel of 8 render + // pixels over a half-period of 4 is twice as wide in the terms that + // matter. The tolerances are set wide of those numbers rather than tight + // to them, because the readback is eight bits and each of the handful of + // distinct output values carries up to half a code value of rounding — a + // floor of a few percent on any of these readings that no amount of + // averaging over a larger frame removes, since the error is periodic + // rather than random. let Some(ctx) = ctx() else { return }; const SOURCE: u32 = 256; const HALF_PERIOD: u32 = 8; // in source pixels @@ -348,19 +426,19 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() { let mut export_pass = AdjustPass::new(&ctx); let export = render(&ctx, &mut export_pass, &graph, &source, (SOURCE, SOURCE)); - let export_amp = modulation(&export, SOURCE, HALF_PERIOD, 0); + let export_amp = modulation(&export, SOURCE, HALF_PERIOD, chroma_r); let proxy_size = SOURCE / 2; let mut proxy_pass = AdjustPass::new(&ctx); let proxy = render(&ctx, &mut proxy_pass, &graph, &source, (proxy_size, proxy_size)); - let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, 0); + let proxy_amp = modulation(&proxy, proxy_size, HALF_PERIOD / 2, chroma_r); // Both must be doing something: two flat images would agree perfectly and - // prove nothing. + // prove nothing. About 0.0098 of 0.0188, by the derivation above. let untouched = { let mut pass = AdjustPass::new(&ctx); let plain = render(&ctx, &mut pass, &graph_with(0.0, 0.0), &source, (SOURCE, SOURCE)); - modulation(&plain, SOURCE, HALF_PERIOD, 0) + modulation(&plain, SOURCE, HALF_PERIOD, chroma_r) }; assert!( export_amp < untouched * 0.8, @@ -368,7 +446,7 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() { ); assert!( - (proxy_amp - export_amp).abs() < export_amp * 0.2, + (proxy_amp - export_amp).abs() < export_amp * 0.25, "the same edit left {proxy_amp} of the pattern on the proxy and \ {export_amp} on the export" ); @@ -387,9 +465,9 @@ fn the_same_edit_denoises_the_same_at_two_resolutions() { (proxy_size, proxy_size), RenderScale::full((proxy_size, proxy_size)), ); - let wrong_amp = modulation(&wrong, proxy_size, HALF_PERIOD / 2, 0); + let wrong_amp = modulation(&wrong, proxy_size, HALF_PERIOD / 2, chroma_r); assert!( - wrong_amp < export_amp * 0.7, + wrong_amp < export_amp * 0.8, "an unconverted radius was indistinguishable from a converted one: \ {wrong_amp} against {export_amp}" ); From 88ce89428b31890463fb81a58920cfd3fb556ab5 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:16:52 +0200 Subject: [PATCH 16/27] Teach the whole-chain shader test about neighbourhood operations `the_whole_chain_at_once_compiles` counted one `---- ` block per operation in the chain. That was true while every operation was a point operation, and stopped being true the moment a neighbourhood one existed: clarity and texture are active in that test, and still emit no fused block, because `compose_full` filters them out and the detail stage dispatches them separately. Counted now by asking each operation whether it has a detail stage -- the same question the composer's own filter asks -- rather than by subtracting a number someone has to remember to update. Capture sharpening and noise reduction are covered by this without another edit. The render at the end is now `render_detailed`, which is not a concession but the stronger test: with a neighbourhood operation active the fused pass hands on linear working values and the last detail pass performs the output transform, so rendering the fused half alone is the mismatch `render_detailed` exists to reject -- and the detail passes are generated WGSL with uniform blocks of their own, which is exactly what "everything at once" is here to collide. It renders at 512 rather than 32 because a compositional radius is a fraction of the frame, and on a 32-pixel target every detail kernel rounds away to nothing. Also records, in `texture_contributes_nothing_where_its_scale_does_not_exist`, the seam this uncovered: an active detail operation whose kernel rounds away composes an empty chain while the fused pass has already been composed to hand on linear values, and nothing can then encode the result. That test now asserts the property on the composed chain instead of driving the unrenderable configuration. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/src/adjust.rs | 59 ++++++++++++++++++++++++++--- core/dr-gpu/tests/local_contrast.rs | 39 ++++++++++++++++--- 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index df7c9d1..1043ad9 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -891,6 +891,11 @@ mod tests { use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; use dr_pipeline::ops::{colour_mixer, exposure, saturation}; use dr_pipeline::EditGraph; + // For `Operation::detail`, which is how `the_whole_chain_at_once_compiles` + // asks the chain which of its operations are neighbourhood operations + // rather than being told a list. Imported anonymously: nothing here names + // the trait, only calls through it. + use dr_pipeline::Operation as _; use crate::Demosaicer; @@ -1423,23 +1428,65 @@ mod tests { } let shader = g.compose(); + + // A neighbourhood operation is active here and still emits no block, + // so the count has to know about them. Clarity, texture, sharpening, + // noise reduction — each is defined by what the pixels *around* a + // pixel are doing, and the fused contract hands a fragment a colour + // with no way back to a coordinate. `compose_full` filters them out + // and the detail stage dispatches them separately, which is the whole + // point of `Affects::Detail`. + // + // Counted by asking each operation whether it has a detail stage — + // the same question the composer's own filter asks — rather than by + // listing the ones that exist today. A detail operation added later is + // then covered without anyone remembering to come back here, which is + // the property this test is supposed to have. + let neighbourhood = dr_pipeline::ops::chain() + .iter() + .filter(|o| o.detail().is_some()) + .count(); + assert!(neighbourhood > 0, "the chain has neighbourhood operations"); assert_eq!( shader.source.matches("---- ").count(), - // Every operation, plus framing — which emits a stage of its own - // rather than an operation block, and is not in `descriptors`. - g.descriptors().len() + 1, - "every operation and the framing should be active" + // Every *point* operation, plus framing — which emits a stage of + // its own rather than an operation block, and is not in + // `descriptors`. + g.descriptors().len() - neighbourhood + 1, + "every point operation and the framing should be active" ); assert!( shader.source.contains("---- framing ----"), "framing must reach the shader alongside the colour operations" ); + // The other half of the same edit, and it belongs in this test for the + // reason the test exists: the detail passes are generated WGSL too, + // they carry their own uniform blocks, and "everything at once" is + // exactly where a collision between them would show. Rendering the + // fused half alone is no longer even legal — with a neighbourhood + // operation active the fused pass stops at linear working values and + // the last detail pass performs the output transform, which is the + // mismatch `render_detailed` exists to reject. + // // Cropped, so the render is against an output size that is not the // source size — the case where a wrong dispatch or a wrong texture // allocation would show up. - let (w, h) = g.output_size(32, 32); - pass.render(&img, &shader, w, h) + // + // At 512 rather than the 32 this test used before the detail stage + // existed. A compositional radius is a fraction of the frame, so on a + // 32-pixel target every detail kernel rounds to a zero-pixel kernel + // and the chain composes nothing at all — honest, but it would leave + // the half of the shader this test came here to compile uncompiled. + let (w, h) = g.output_size(512, 512); + let scale = g.render_scale((512, 512), (w, h)); + let detail = g.compose_detail_for(scale, dr_types::ColourSpace::Srgb); + assert!( + !detail.is_empty(), + "the detail half composed nothing, so nothing of it was compiled" + ); + let key = g.invalidation().through(dr_pipeline::Affects::Colour); + pass.render_detailed(&img, &shader, w, h, None, &detail, key) .expect("the full chain must compile"); } diff --git a/core/dr-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs index f93e328..9611940 100644 --- a/core/dr-gpu/tests/local_contrast.rs +++ b/core/dr-gpu/tests/local_contrast.rs @@ -547,18 +547,45 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() { let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]); let mut graph = graph_with(TEXTURE, 100.0); + + // Asserted on the composed chain rather than on a dispatch counter, + // because texture *alone* at this size is a configuration the stage as a + // whole cannot currently render, and that is a gap in the seam rather than + // in this operation. + // + // `compose_full` decides whether the fused pass should hand on linear + // working values from `is_active()`, which has no `RenderScale` to consult; + // `compose_detail` decides what to dispatch from the kernel it can actually + // draw at this scale. Almost always the two agree. They disagree exactly + // when a detail operation is active and its kernel rounds away, and then + // `render_detailed` finds an empty chain, falls through to `render_masked`, + // and is rejected for handing a linear-working shader to the plain path. + // + // Pre-existing, and not something clarity and texture introduce: + // `DetailStage::passes` documents the empty return as *the honest answer + // for an acutance operation on a heavy proxy*, so capture sharpening and + // noise reduction reach it by the same road. Fixing it means composing + // both halves of a render together, so the fused half can know whether a + // detail half survived the scale — a change at the composition boundary, + // not in this file. + let scale = graph.render_scale(source.size(), (128, 128)); + assert!( + graph.compose_detail_for(scale, ColourSpace::Srgb).is_empty(), + "texture claimed a kernel it cannot draw" + ); + + // With clarity on as well the edit is renderable again, and the dispatch + // count says what the assertion above says: two passes, not four. Texture + // is active, and contributes nothing. + graph.set_param(CLARITY, AMOUNT, 100.0); let mut pass = AdjustPass::new(&ctx); render(&mut pass, &graph, &source, 128); assert_eq!( pass.detail_dispatches(), - 0, - "texture claimed a kernel it cannot draw" + 2, + "clarity survives a thumbnail, and texture added nothing beside it" ); - graph.set_param(CLARITY, AMOUNT, 100.0); - render(&mut pass, &graph, &source, 128); - assert_eq!(pass.detail_dispatches(), 2, "clarity survives a thumbnail"); - // And texture comes back, exactly, as soon as the view is large enough to // hold it — no separate path, no fade, just the kernel resolving again. let mut zoomed = AdjustPass::new(&ctx); From e44c929afc4d1a681993ce40eccbd29a887d8c74 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:17:05 +0200 Subject: [PATCH 17/27] Guard the sharpening kernel against WGSL reserved keywords The fused-fragment check in lib.rs cannot see a detail pass: it is a separate shader composed at a resolution compose() never knows. The kernel's own test now scans the block the composer wrapped, with comments stripped so prose about the keyword cannot fail a test about the code. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-pipeline/src/ops/capture_sharpen.rs | 32 +++++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/core/dr-pipeline/src/ops/capture_sharpen.rs b/core/dr-pipeline/src/ops/capture_sharpen.rs index c938fe3..d727a19 100644 --- a/core/dr-pipeline/src/ops/capture_sharpen.rs +++ b/core/dr-pipeline/src/ops/capture_sharpen.rs @@ -799,9 +799,8 @@ mod tests { // source rather than at the operation that wrote it, and this body // reaches for exactly that word. // - // Not the full reserved list; the words a convolution would plausibly - // pick for a local. - // The same list `no_fragment_declares_a_wgsl_reserved_keyword` uses, + // Not the full reserved list; the words a kernel would plausibly pick + // for a local. The same list `no_fragment_declares_a_wgsl_reserved_keyword` uses, // kept identical on purpose: two lists that drift apart would let a // word be safe in one stage and not in the other, which is the sort of // difference nobody discovers until a shader fails to compile. @@ -813,18 +812,33 @@ mod tests { ]; for pass in chain_at(&sharpening(100.0, 2.0), RenderScale::full((512, 512))).passes { - // Only what this operation wrote. The composer's own preamble - // declares `var output` and `let coord`, which naga accepts and - // which are not this test's business. - let body = pass + // Only what this operation wrote — the block the composer wraps + // the body in. Its preamble declares `var output` and its tail + // writes through it, and neither is this test's business; scanning + // the whole file would fail on generated code nobody here can fix. + let block = pass .source .rsplit_once(" {\n") - .expect("the operation's block") + .expect("the operation's block opens") .1; + let body = block + .split_once("\n }\n") + .map_or(block, |(inside, _)| inside); + + // Comments are not declarations, and the kernel deliberately names + // `target` in one to say why it does not use it. Stripping them + // keeps this on the code, so that editing the prose can never fail + // a test about what compiles. + let code: Vec<&str> = body + .lines() + .filter(|l| !l.trim_start().starts_with("//")) + .collect(); + let code = code.join("\n"); + for keyword in RESERVED { for form in [format!("let {keyword} "), format!("var {keyword} ")] { assert!( - !body.contains(&form), + !code.contains(&form), "{} declares `{keyword}`, which is a WGSL reserved keyword", pass.label ); From c9305fd0e64d87c3e507357b6d1b88db89a43bee Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:18:13 +0200 Subject: [PATCH 18/27] Write out the derivation behind every kernel width these tests assert A test that cannot be run cannot be checked by running it, and a number copied out of a test run agrees with whatever the code did on the day. Each asserted kernel width, tolerance and overshoot bound now carries the arithmetic that produces it -- the shorter edge, the sigma, the truncation at two sigmas, and where the rounding falls -- so a reader can verify the expectation against the recipe without a GPU or a compiler. Also records the two places where a bound is a bound and not a measurement: the tolerance in the frame-fraction test is exactly what rounding a kernel to a whole pixel costs on the smallest frame it uses, and the halo test's floor and ceiling bracket a peak derived from the step, the soft limit and the midtone taper rather than from a run. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/tests/local_contrast.rs | 13 ++++++ core/dr-pipeline/src/ops/local_contrast.rs | 54 +++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/core/dr-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs index 9611940..b78e049 100644 --- a/core/dr-gpu/tests/local_contrast.rs +++ b/core/dr-gpu/tests/local_contrast.rs @@ -216,6 +216,19 @@ fn the_soft_limit_bounds_the_halo_at_a_hard_edge() { .fold(0.0f32, f32::max); let bound = Clarity::with_amount(100.0).overshoot_bound(); + // Where the two comparisons below sit, derived rather than observed: + // + // srgb_decode(175) = 0.4287, srgb_decode(90) = 0.1022 + // the step is log2(0.4287 / 0.1022) = 2.069 stops + // an unlimited mask peaks at half of it = 1.034 stops + // the soft limit saturates at = 0.350 stops (`bound`) + // the midtone taper then takes about 13% off at 175, so the peak this + // test should actually see is near = 0.30 stops + // + // So 0.30 has to clear the 0.1 floor with room, and fall well under both + // 0.35 + slack and 0.6 × 1.034 = 0.62. Every one of those is a bound with + // a reason, not a tolerance widened until the test passed. + // // A code value's worth of slack: the readback is 8-bit, and a pixel // sitting exactly on the bound quantises either side of it. assert!( diff --git a/core/dr-pipeline/src/ops/local_contrast.rs b/core/dr-pipeline/src/ops/local_contrast.rs index 5724027..29de880 100644 --- a/core/dr-pipeline/src/ops/local_contrast.rs +++ b/core/dr-pipeline/src/ops/local_contrast.rs @@ -633,7 +633,15 @@ mod tests { let scale = RenderScale::full((4000, 3000)); let clarity = Clarity::with_amount(100.0).kernel(scale); let texture = Texture::with_amount(100.0).kernel(scale); - // 1.2% of the *shorter* edge — 3000, not 4000 — and two sigmas wide. + // Both derived rather than observed, because a number copied out of a + // test run agrees with whatever the code did on the day. + // + // `frame_fraction` takes the *shorter* edge: min(4000, 3000) = 3000. + // clarity σ = 0.012 × 3000 = 36.0 → round(36.0 × 2) = 72 + // texture σ = 0.0012 × 3000 = 3.6 → round( 3.6 × 2) = 7 + // + // (7.2 rounds down, which is why texture is 7 and not 8 — the + // truncation is two sigmas, and two sigmas of 3.6 px is 7.2 px.) assert_eq!(clarity, 72); assert_eq!(texture, 7, "a decade finer"); assert!( @@ -652,6 +660,16 @@ mod tests { // must cover the same *proportion* of the picture on a proxy as in the // export, which is what `frame_fraction` gives and what // `source_pixels` would not. + // The declared proportion is σ × 2 = 0.012 × 2 = 0.024 of the shorter + // edge, and the tolerance is what rounding to a whole pixel costs: + // + // 300 → round(0.024 × 300) = 7 → 7/300 = 0.02333 (−0.00067) + // 1500 → round(0.024 × 1500) = 36 → 36/1500 = 0.02400 ( 0.00000) + // 4500 → round(0.024 × 4500) = 108 → 108/4500 = 0.02400 ( 0.00000) + // + // Half a pixel over the smallest frame here is 0.5/300 = 0.0017, so + // 0.002 is the tolerance a rounded kernel can actually hold — and it + // is a bound, not a fitted number. let sizes = [(400u32, 300u32), (2000, 1500), (6000, 4500)]; let proportions: Vec = sizes .iter() @@ -670,6 +688,17 @@ mod tests { // And the contrast with the other unit, stated rather than implied: on // a one-third proxy a source-pixel radius *shrinks* to a third of the // proportion it had, which is the bug this choice avoids. + // + // ratio = (2000/6000 + 1500/4500) / 2 = 1/3 + // frame_fraction(0.012) → 0.012 × 1500 = 18 px, the same 18 px the + // export of that framing gets, because the + // unit is a proportion of what is rendered; + // source_pixels(96) → 96 × 1/3 = 32 px, a third of the reach + // the same edit had at full size. + // + // 96 is clarity's kernel at 4000 px of shorter edge, so the second line + // is what this control would have done had it been written in the + // acutance family's unit. let proxy = RenderScale::new((2000, 1500), (6000, 4500)); let clarity = Clarity::with_amount(60.0); assert_eq!(clarity.kernel(proxy), clarity.kernel(RenderScale::full((2000, 1500)))); @@ -685,6 +714,15 @@ mod tests { // rendering of the frame, so the honest thing is to contribute no // pass. Unlike the acutance family this is not an approximation being // hidden: zoom in and the kernel comes back, exactly. + // + // shorter edge = 120 + // texture σ = 0.0012 × 120 = 0.144 → round(0.288) = 0 — no pass + // clarity σ = 0.012 × 120 = 1.44 → round(2.88) = 3 — two passes + // + // Texture's kernel crosses back above zero at round(0.0024 × e) ≥ 1, + // i.e. a shorter edge of about 209 px, which is a little larger than a + // contact sheet thumbnail and a great deal smaller than any view a + // photographer judges surface detail in. let thumbnail = RenderScale::full((160, 120)); assert_eq!(Texture::with_amount(100.0).kernel(thumbnail), 0); assert!(Texture::with_amount(100.0).passes(thumbnail).is_empty()); @@ -724,6 +762,14 @@ mod tests { // and nothing can infer that from the WGSL because the offsets come // from a uniform. An understated radius shows as a seam at every tile // boundary — an artefact that reads as a driver bug. + // + // shorter edge = 1500 + // clarity → round(0.024 × 1500) = 36 px + // texture → round(0.0024 × 1500) = 4 px + // + // so the halo the four passes together need is clarity's 36, taken as + // the maximum rather than the sum: the passes are separate dispatches, + // and a tile is grown for whichever of them reaches furthest. let scale = RenderScale::full((2000, 1500)); let composed = composed(50.0, 50.0, scale); let clarity = Clarity::with_amount(50.0).kernel(scale); @@ -749,6 +795,12 @@ mod tests { // A third of a stop for clarity, before the midtone taper takes more // off; a little over a stop for texture, whose overshoot is two pixels // wide and reads as acutance. + // + // clarity gain = 100/100 × 1.00 = 1.00 ; × 0.35 = 0.35 stops (26%) + // texture gain = 100/100 × 1.25 = 1.25 ; × 1.00 = 1.25 stops + // + // Both at full travel, which is what makes them a bound and not a + // measurement: no picture, and no edge in any picture, can produce more. assert!((bound(&combine.uniforms) - 0.35).abs() < 1e-6); let texture = &Texture::with_amount(100.0).passes(RenderScale::full((2000, 1500)))[1]; assert!((bound(&texture.uniforms) - 1.25).abs() < 1e-6); From 2459a759afa782fa32d997d96e78ec7f87e8af6c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:18:56 +0200 Subject: [PATCH 19/27] Compile the detail stage in the whole-chain GPU tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit every_operation_generates_compilable_wgsl and the_whole_chain_at_once_compiles both rendered through the fused half only. A neighbourhood operation contributes no fused fragment, so its kernels went uncompiled — and once one is active the fused pass hands on linear working values, which plain render refuses. Both now compose both halves from the one graph, and the fused block count excludes the operations the detail chain names. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/src/adjust.rs | 71 ++++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index df7c9d1..f9a261a 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -1063,12 +1063,32 @@ mod tests { let mut g = EditGraph::default_chain(); g.set_param(cap.id, p.id, value); let shader = g.compose(); - pass.render(&img, &shader, 16, 16).unwrap_or_else(|e| { - panic!( - "{}.{} at {value} generated invalid WGSL:\n{e}", - cap.id, p.id - ) - }); + + // Through the detail stage rather than through `render`, + // because a neighbourhood operation contributes no fused + // fragment: its WGSL is generated per resolution and lives + // in dispatches of its own. Compiling only the fused half + // would leave every kernel in the chain untested here — + // and worse, `render` refuses a shader composed to hand on + // linear working values, so the omission would arrive as + // "invalid WGSL" against a shader that is perfectly valid. + // + // The scale comes from the graph, so the kernel really is + // converted the way a render converts it. The image is + // 16×16 and so is the target, which puts the ratio at 1.0 + // and keeps an acutance operation from declining to draw + // (`RenderScale::resolves`) and compiling its pass-through + // instead of the kernel this test exists to check. + let scale = g.render_scale(img.size(), (16, 16)); + let detail = g.compose_detail(scale); + let key = g.invalidation().through(dr_pipeline::Affects::Colour); + pass.render_detailed(&img, &shader, 16, 16, None, &detail, key) + .unwrap_or_else(|e| { + panic!( + "{}.{} at {value} generated invalid WGSL:\n{e}", + cap.id, p.id + ) + }); } } } @@ -1423,23 +1443,44 @@ mod tests { } let shader = g.compose(); + + // Cropped, so the render is against an output size that is not the + // source size — the case where a wrong dispatch or a wrong texture + // allocation would show up. + let (w, h) = g.output_size(32, 32); + let scale = g.render_scale(img.size(), (w, h)); + let detail = g.compose_detail(scale); + + // A neighbourhood operation is active and yet emits no fused block: it + // reads pixels it is not writing, so it is a dispatch of its own. The + // ones that are come from the detail chain rather than from a list + // here, which keeps the count exact as sharpening, noise reduction and + // clarity arrive instead of loosening it to an inequality. + let neighbourhood: std::collections::BTreeSet<&str> = detail + .passes + .iter() + .map(|p| p.label.split('/').next().expect("/")) + .collect(); + assert_eq!( shader.source.matches("---- ").count(), - // Every operation, plus framing — which emits a stage of its own - // rather than an operation block, and is not in `descriptors`. - g.descriptors().len() + 1, - "every operation and the framing should be active" + // Every fusable operation, plus framing — which emits a stage of + // its own rather than an operation block, and is not in + // `descriptors` — less the ones that run after this shader. + g.descriptors().len() + 1 - neighbourhood.len(), + "every fusable operation and the framing should be active" ); assert!( shader.source.contains("---- framing ----"), "framing must reach the shader alongside the colour operations" ); - // Cropped, so the render is against an output size that is not the - // source size — the case where a wrong dispatch or a wrong texture - // allocation would show up. - let (w, h) = g.output_size(32, 32); - pass.render(&img, &shader, w, h) + // Both halves, from the one graph: with a detail stage present the + // fused pass stops at linear working values and the last detail pass + // performs the output transform, so rendering only the first half is + // not a smaller test — it is a texture format the driver rejects. + let key = g.invalidation().through(dr_pipeline::Affects::Colour); + pass.render_detailed(&img, &shader, w, h, None, &detail, key) .expect("the full chain must compile"); } From eb229051ef8f12ec22a2736fb383018f26f299f6 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:20:22 +0200 Subject: [PATCH 20/27] Teach the whole-chain GPU tests about neighbourhood operations Both tests composed only the fused half and rendered it through the plain path. That was correct while every operation was a point operation; with a kernel in the chain the fused pass stops short of the output transform, so the render was rejected and the operation-block count was one too high. Compose both halves and dispatch them together, and assert that each operation reaches exactly one of the two stages rather than counting blocks - so the next kernel added extends the coverage instead of breaking it. --- core/dr-gpu/src/adjust.rs | 85 ++++++++++++++++++++++++++++++++------- 1 file changed, 70 insertions(+), 15 deletions(-) diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index df7c9d1..a47e85d 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -1063,12 +1063,31 @@ mod tests { let mut g = EditGraph::default_chain(); g.set_param(cap.id, p.id, value); let shader = g.compose(); - pass.render(&img, &shader, 16, 16).unwrap_or_else(|e| { - panic!( - "{}.{} at {value} generated invalid WGSL:\n{e}", - cap.id, p.id - ) - }); + + // A neighbourhood operation compiles as a *chain*, not as + // a fragment: it contributes nothing to the fused pass, + // and the fused pass in turn stops short of the output + // transform so the last detail pass can perform it. Going + // through `render_detailed` covers both kinds with one + // loop, which is the property that makes this test extend + // itself when an operation is added — the whole reason it + // is derived from the chain rather than hand-written. + // + // Composed at the size actually being rendered, because a + // radius stated in source pixels can decide there is + // nothing to draw at sixteen pixels (FR-DSP-1); the chain + // still carries the resolve pass that finishes the render, + // and that generated source is worth compiling too. + let scale = g.render_scale(img.size(), (16, 16)); + let detail = g.compose_detail(scale); + let key = g.invalidation().through(dr_pipeline::Affects::Colour); + pass.render_detailed(&img, &shader, 16, 16, None, &detail, key) + .unwrap_or_else(|e| { + panic!( + "{}.{} at {value} generated invalid WGSL:\n{e}", + cap.id, p.id + ) + }); } } } @@ -1422,24 +1441,60 @@ mod tests { } } + // Cropped, so the render is against an output size that is not the + // source size — the case where a wrong dispatch or a wrong texture + // allocation would show up. + let (w, h) = g.output_size(32, 32); let shader = g.compose(); + let scale = g.render_scale(img.size(), (w, h)); + let detail = g.compose_detail(scale); + + // Every operation has to reach the pipeline, but they do not all reach + // the same half of it, and which half is not this test's business to + // know: a point operation is a block in the fused shader, and a + // neighbourhood operation is one or more passes of the detail chain + // (`dr_pipeline::detail`) and contributes *no* fused block, because a + // fused fragment is handed a colour with no way back to a coordinate. + // + // Asserted as an exclusive or over the chain rather than as a count, + // so that adding either kind of operation extends this test on its own + // — and so that an operation which somehow managed both, or neither, + // is named rather than showing up as an arithmetic mismatch. + let mut fused_blocks = 0; + for desc in g.descriptors() { + let id = desc.id.0; + let point = shader.source.contains(&format!("---- {id} ----")); + let neighbourhood = detail + .passes + .iter() + .any(|p| p.label.starts_with(&format!("{id}/"))); + assert!( + point ^ neighbourhood, + "{id} reaches {} of the two stages; every active operation \ + belongs to exactly one", + if point { "both" } else { "neither" } + ); + fused_blocks += usize::from(point); + } + assert_eq!( shader.source.matches("---- ").count(), - // Every operation, plus framing — which emits a stage of its own - // rather than an operation block, and is not in `descriptors`. - g.descriptors().len() + 1, - "every operation and the framing should be active" + // The fused operations, plus framing — which emits a stage of its + // own rather than an operation block, and is not in `descriptors`. + fused_blocks + 1, + "the fused shader carries a block nothing in the chain asked for" ); assert!( shader.source.contains("---- framing ----"), "framing must reach the shader alongside the colour operations" ); + assert!( + !detail.is_empty(), + "with every operation active the detail stage must run" + ); - // Cropped, so the render is against an output size that is not the - // source size — the case where a wrong dispatch or a wrong texture - // allocation would show up. - let (w, h) = g.output_size(32, 32); - pass.render(&img, &shader, w, h) + let key = g.invalidation().through(dr_pipeline::Affects::Colour); + pass.render_detailed(&img, &shader, w, h, None, &detail, key) .expect("the full chain must compile"); } From 690e76a51fdca3b636fd0326dd8890fc850b6765 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:21:59 +0200 Subject: [PATCH 21/27] Let the fully-active chain test account for both stages Activating every operation now activates a kernel too, and a kernel emits no block in the fused shader. Assert that each operation reaches exactly one of the fused pass and the detail chain, rather than counting fused blocks against the length of the chain. --- core/dr-pipeline/src/lib.rs | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index a763ef9..a651c3a 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -107,12 +107,38 @@ mod tests { let g = fully_active(); assert!(!g.is_neutral()); let shader = g.compose(); - // Counted against the chain rather than a literal, so adding an - // operation does not require editing this test. + + // The chain has two kinds of operation in it and they arrive in + // different places: a point operation is a block in the fused shader, + // while a neighbourhood operation is a pass of the detail chain and + // contributes no fused block at all — it reads pixels it is not + // writing, and a fused fragment is handed a colour with no coordinate. + // + // So the assertion is that each operation reaches exactly one of the + // two, checked against the chain rather than a literal, and phrased so + // that adding either kind extends it without an edit here. + let scale = g.render_scale((4000, 3000), (4000, 3000)); + let detail = g.compose_detail(scale); + let mut fused_blocks = 0; + for desc in g.descriptors() { + let id = desc.id.0; + let point = shader.source.contains(&format!("---- {id} ----")); + let neighbourhood = detail + .passes + .iter() + .any(|p| p.label.starts_with(&format!("{id}/"))); + assert!( + point ^ neighbourhood, + "{id} reaches {} of the two stages; an active operation \ + belongs to exactly one", + if point { "both" } else { "neither" } + ); + fused_blocks += usize::from(point); + } assert_eq!( shader.source.matches("---- ").count(), - g.descriptors().len(), - "every operation in the chain should appear" + fused_blocks, + "the fused shader carries a block nothing in the chain asked for" ); } From 7031352e85d7ca44fa957c897e1a29d98ca97cde Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:23:43 +0200 Subject: [PATCH 22/27] Keep neighbourhood operations out of a mask layer's chain A layer holds a full chain and fuses it into the colour dispatch, so the panel - which names no operation - would have offered a noise reduction slider inside a local adjustment. It could not have worked: the detail stage is its own dispatch, running after the masks are already applied, with nowhere to be handed one layer's mask. The control would have moved and done nothing. Filter the layer's chain to the operations that can honour it. --- core/dr-pipeline/src/mask.rs | 72 +++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/core/dr-pipeline/src/mask.rs b/core/dr-pipeline/src/mask.rs index 76c7d9c..b35b4f2 100644 --- a/core/dr-pipeline/src/mask.rs +++ b/core/dr-pipeline/src/mask.rs @@ -660,12 +660,37 @@ pub struct MaskLayer { pub ops: Vec>, } +/// The chain a mask layer holds: every point operation, and none of the +/// neighbourhood ones. +/// +/// A layer's adjustments are fused into the colour dispatch and multiplied by +/// the mask afterwards, which is exactly why a layer needs no per-operation +/// support — the composer already knows how to turn a chain into WGSL. A +/// neighbourhood operation cannot go through that path at all: it runs as its +/// own dispatch in [`crate::detail`], after the fused pass and after the masks +/// have already been applied, and there is nowhere in that arrangement for it +/// to be given one layer's mask. +/// +/// Left in, it would be worse than absent. `Operation::wgsl_body` returns an +/// empty string for a detail operation, so the layer would emit an empty block +/// and the panel — which builds itself from [`MaskLayer::capabilities`] and +/// names no operation — would offer a slider that moved and did nothing. +/// Filtering here means a local sharpening or denoise control simply does not +/// appear until there is a stage that can honour it, which is the honest +/// state of affairs. +fn layer_chain() -> Vec> { + ops::chain() + .into_iter() + .filter(|o| o.detail().is_none()) + .collect() +} + impl Clone for MaskLayer { /// Cloned by *value*, not by handle: the ops are trait objects, so this /// rebuilds a fresh chain and copies the parameters across. Needed because /// the UI edits a layer speculatively and the history stores snapshots. fn clone(&self) -> Self { - let mut ops = ops::chain(); + let mut ops = layer_chain(); for (dst, src) in ops.iter_mut().zip(&self.ops) { for p in src.descriptor().params { dst.set_param(p.id, src.param(p.id)); @@ -738,7 +763,7 @@ impl MaskLayer { falloff: Falloff::default(), morphology: Morphology::default(), morph_radius: 0.0, - ops: ops::chain(), + ops: layer_chain(), } } @@ -1276,6 +1301,49 @@ mod tests { assert_eq!(stack.len(), 1, "but they are not deleted"); } + #[test] + fn a_layer_offers_only_the_operations_it_can_actually_apply() { + // A layer's adjustments are fused into the colour dispatch and then + // multiplied by the mask. A neighbourhood operation cannot take that + // route: it is a dispatch of its own, run after the fused pass and + // after the masks are already applied, so there is nowhere to hand it + // one layer's mask. + // + // The panel builds itself from `capabilities()` and names no + // operation, so anything left in this chain becomes a control. One + // that cannot work is worse than one that is missing: it moves, the + // picture does not change, and nothing says why. + let layer = lit_layer("m1", 1.0); + let ids: Vec<&str> = layer.capabilities().iter().map(|c| c.id.0).collect(); + + let global = crate::ops::chain(); + for op in &global { + let id = op.descriptor().id.0; + assert_eq!( + ids.contains(&id), + op.detail().is_none(), + "{id} is offered as a local adjustment but cannot be one, \ + or is a point operation and has gone missing from a layer" + ); + } + assert!( + ids.len() < global.len() || global.iter().all(|o| o.detail().is_none()), + "the filter dropped nothing, so either it is not running or the \ + chain has no neighbourhood operation left to drop" + ); + + // And a clone must rebuild the same chain: it copies parameters across + // by position, so a chain built one way and rebuilt another would + // silently apply each value to the wrong operation. + let cloned: Vec<&str> = layer + .clone() + .capabilities() + .iter() + .map(|c| c.id.0) + .collect(); + assert_eq!(ids, cloned); + } + #[test] fn zero_opacity_is_inactive() { let mut layer = lit_layer("m1", 1.0); From 5d15b753f778af7f82c35996eb2b969becfab40c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:31:22 +0200 Subject: [PATCH 23/27] Regenerate the traceability matrix Five branches merged since the last regeneration. Generated file, so the only correct version is the one produced once, here, from the merged source. --- docs/traceability.md | 52 ++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/traceability.md b/docs/traceability.md index 669bad6..e6a402a 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 160 | -| TRACES tags found | 436 | +| Source files scanned | 172 | +| TRACES tags found | 488 | | Requirements defined | 175 | | Requirements covered | 86 | | **Coverage** | **49.1%** (86/175) | @@ -42,38 +42,38 @@ _None._ | 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:479`](../tools/traceability/src/lib.rs#L479) | | FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../core/dr-catalog/src/walk.rs#L66), [`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/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs:264`](../core/dr-decode/src/lib.rs#L264), [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library_ui.rs:5224`](../ui/dr-ui/src/library_ui.rs#L5224), [`ui/dr-ui/src/library_ui.rs:5235`](../ui/dr-ui/src/library_ui.rs#L5235), [`ui/dr-ui/src/library_ui.rs:5248`](../ui/dr-ui/src/library_ui.rs#L5248), [`ui/dr-ui/src/library_ui.rs:5263`](../ui/dr-ui/src/library_ui.rs#L5263), [`ui/dr-ui/src/library_ui.rs:5272`](../ui/dr-ui/src/library_ui.rs#L5272), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-catalog/src/schema.rs:783`](../core/dr-catalog/src/schema.rs#L783), [`core/dr-decode/src/lib.rs:285`](../core/dr-decode/src/lib.rs#L285), [`core/dr-decode/src/lib.rs:401`](../core/dr-decode/src/lib.rs#L401), [`core/dr-pipeline/src/sidecar.rs:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library_ui.rs:5224`](../ui/dr-ui/src/library_ui.rs#L5224), [`ui/dr-ui/src/library_ui.rs:5235`](../ui/dr-ui/src/library_ui.rs#L5235), [`ui/dr-ui/src/library_ui.rs:5248`](../ui/dr-ui/src/library_ui.rs#L5248), [`ui/dr-ui/src/library_ui.rs:5263`](../ui/dr-ui/src/library_ui.rs#L5263), [`ui/dr-ui/src/library_ui.rs:5272`](../ui/dr-ui/src/library_ui.rs#L5272), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:18`](../ui/dr-ui/ui/library.slint#L18), [`ui/dr-ui/ui/library.slint:685`](../ui/dr-ui/ui/library.slint#L685), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | | FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../core/dr-catalog/src/keywords.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-catalog/src/schema.rs:280`](../core/dr-catalog/src/schema.rs#L280), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/ui/app.slint:592`](../ui/dr-ui/ui/app.slint#L592), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | | 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:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`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/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:716`](../ui/dr-ui/ui/library.slint#L716) | -| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:141`](../core/dr-pipeline/src/ops/curve.rs#L141), [`core/dr-pipeline/src/ops/curve.rs:657`](../core/dr-pipeline/src/ops/curve.rs#L657), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/tests/tone_curve.rs:32`](../core/dr-pipeline/tests/tone_curve.rs#L32), [`ui/dr-ui/src/develop.rs:2194`](../ui/dr-ui/src/develop.rs#L2194), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/lib.rs:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/lib.rs:1528`](../ui/dr-ui/src/lib.rs#L1528), [`ui/dr-ui/src/lib.rs:462`](../ui/dr-ui/src/lib.rs#L462), [`ui/dr-ui/src/lib.rs:766`](../ui/dr-ui/src/lib.rs#L766), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4235`](../ui/dr-ui/src/library_ui.rs#L4235), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | -| 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:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`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:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1902`](../ui/dr-ui/src/develop.rs#L1902), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2541`](../ui/dr-ui/src/library_ui.rs#L2541), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:3004`](../ui/dr-ui/src/library_ui.rs#L3004), [`ui/dr-ui/src/library_ui.rs:3076`](../ui/dr-ui/src/library_ui.rs#L3076), [`ui/dr-ui/src/library_ui.rs:3241`](../ui/dr-ui/src/library_ui.rs#L3241), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4465`](../ui/dr-ui/src/library_ui.rs#L4465), [`ui/dr-ui/src/library_ui.rs:4580`](../ui/dr-ui/src/library_ui.rs#L4580), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-CAT-8 | [`core/dr-pipeline/src/ops/curve.rs:141`](../core/dr-pipeline/src/ops/curve.rs#L141), [`core/dr-pipeline/src/ops/curve.rs:657`](../core/dr-pipeline/src/ops/curve.rs#L657), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`core/dr-pipeline/tests/tone_curve.rs:32`](../core/dr-pipeline/tests/tone_curve.rs#L32), [`ui/dr-ui/src/develop.rs:2237`](../ui/dr-ui/src/develop.rs#L2237), [`ui/dr-ui/src/export.rs:749`](../ui/dr-ui/src/export.rs#L749), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/lib.rs:1423`](../ui/dr-ui/src/lib.rs#L1423), [`ui/dr-ui/src/lib.rs:1528`](../ui/dr-ui/src/lib.rs#L1528), [`ui/dr-ui/src/lib.rs:462`](../ui/dr-ui/src/lib.rs#L462), [`ui/dr-ui/src/lib.rs:766`](../ui/dr-ui/src/lib.rs#L766), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library_ui.rs:4235`](../ui/dr-ui/src/library_ui.rs#L4235), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | +| 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:337`](../core/dr-catalog/src/schema.rs#L337), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:704`](../core/dr-catalog/src/walk.rs#L704), [`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:115`](../core/dr-types/src/lib.rs#L115), [`ui/dr-ui/src/develop.rs:1945`](../ui/dr-ui/src/develop.rs#L1945), [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138), [`ui/dr-ui/src/library.rs:1435`](../ui/dr-ui/src/library.rs#L1435), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:196`](../ui/dr-ui/src/library.rs#L196), [`ui/dr-ui/src/library.rs:2791`](../ui/dr-ui/src/library.rs#L2791), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:610`](../ui/dr-ui/src/library.rs#L610), [`ui/dr-ui/src/library.rs:626`](../ui/dr-ui/src/library.rs#L626), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library_ui.rs:1443`](../ui/dr-ui/src/library_ui.rs#L1443), [`ui/dr-ui/src/library_ui.rs:1469`](../ui/dr-ui/src/library_ui.rs#L1469), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:1579`](../ui/dr-ui/src/library_ui.rs#L1579), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:198`](../ui/dr-ui/src/library_ui.rs#L198), [`ui/dr-ui/src/library_ui.rs:2112`](../ui/dr-ui/src/library_ui.rs#L2112), [`ui/dr-ui/src/library_ui.rs:2541`](../ui/dr-ui/src/library_ui.rs#L2541), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:3004`](../ui/dr-ui/src/library_ui.rs#L3004), [`ui/dr-ui/src/library_ui.rs:3076`](../ui/dr-ui/src/library_ui.rs#L3076), [`ui/dr-ui/src/library_ui.rs:3241`](../ui/dr-ui/src/library_ui.rs#L3241), [`ui/dr-ui/src/library_ui.rs:352`](../ui/dr-ui/src/library_ui.rs#L352), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/library_ui.rs:4465`](../ui/dr-ui/src/library_ui.rs#L4465), [`ui/dr-ui/src/library_ui.rs:4580`](../ui/dr-ui/src/library_ui.rs#L4580), [`ui/dr-ui/src/presets.rs:284`](../ui/dr-ui/src/presets.rs#L284), [`ui/dr-ui/src/presets.rs:296`](../ui/dr-ui/src/presets.rs#L296), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.rs#L1) | | 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:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1768`](../core/dr-gpu/src/adjust.rs#L1768), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:333`](../core/dr-pipeline/src/detail.rs#L333), [`core/dr-pipeline/src/detail.rs:408`](../core/dr-pipeline/src/detail.rs#L408), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1255`](../ui/dr-ui/src/develop.rs#L1255), [`ui/dr-ui/src/develop.rs:1270`](../ui/dr-ui/src/develop.rs#L1270), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1375`](../ui/dr-ui/src/develop.rs#L1375), [`ui/dr-ui/src/develop.rs:1449`](../ui/dr-ui/src/develop.rs#L1449), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2552`](../ui/dr-ui/src/develop.rs#L2552), [`ui/dr-ui/src/develop.rs:2606`](../ui/dr-ui/src/develop.rs#L2606), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1119`](../ui/dr-ui/src/lib.rs#L1119), [`ui/dr-ui/src/lib.rs:1771`](../ui/dr-ui/src/lib.rs#L1771), [`ui/dr-ui/src/lib.rs:288`](../ui/dr-ui/src/lib.rs#L288), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772) | -| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:925`](../core/dr-pipeline/src/mask.rs#L925), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:524`](../ui/dr-ui/src/lib.rs#L524) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1849`](../core/dr-gpu/src/adjust.rs#L1849), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1298`](../ui/dr-ui/src/develop.rs#L1298), [`ui/dr-ui/src/develop.rs:1313`](../ui/dr-ui/src/develop.rs#L1313), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1418`](../ui/dr-ui/src/develop.rs#L1418), [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2595`](../ui/dr-ui/src/develop.rs#L2595), [`ui/dr-ui/src/develop.rs:2649`](../ui/dr-ui/src/develop.rs#L2649), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1119`](../ui/dr-ui/src/lib.rs#L1119), [`ui/dr-ui/src/lib.rs:1771`](../ui/dr-ui/src/lib.rs#L1771), [`ui/dr-ui/src/lib.rs:288`](../ui/dr-ui/src/lib.rs#L288), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772) | +| FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:524`](../ui/dr-ui/src/lib.rs#L524) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) | -| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:925`](../core/dr-pipeline/src/mask.rs#L925), [`ui/dr-ui/src/develop.rs:3185`](../ui/dr-ui/src/develop.rs#L3185) | -| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/detail_stage.rs:240`](../core/dr-gpu/tests/detail_stage.rs#L240), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:420`](../core/dr-pipeline/src/graph.rs#L420), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | -| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:100`](../core/dr-decode/src/lib.rs#L100), [`core/dr-decode/src/lib.rs:632`](../core/dr-decode/src/lib.rs#L632), [`core/dr-decode/src/lib.rs:672`](../core/dr-decode/src/lib.rs#L672), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1344`](../core/dr-pipeline/src/operation.rs#L1344), [`core/dr-pipeline/src/operation.rs:1369`](../core/dr-pipeline/src/operation.rs#L1369), [`core/dr-pipeline/src/operation.rs:1384`](../core/dr-pipeline/src/operation.rs#L1384), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:513`](../core/dr-pipeline/src/operation.rs#L513) | -| FR-DEV-3h | [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | +| FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`ui/dr-ui/src/develop.rs:3228`](../ui/dr-ui/src/develop.rs#L3228) | +| FR-DEV-3d | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:709`](../core/dr-gpu/src/adjust.rs#L709), [`core/dr-gpu/src/adjust.rs:764`](../core/dr-gpu/src/adjust.rs#L764), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/src/adjust.rs:97`](../core/dr-gpu/src/adjust.rs#L97), [`core/dr-gpu/tests/capture_sharpen.rs:431`](../core/dr-gpu/tests/capture_sharpen.rs#L431), [`core/dr-gpu/tests/detail_stage.rs:240`](../core/dr-gpu/tests/detail_stage.rs#L240), [`core/dr-gpu/tests/local_contrast.rs:467`](../core/dr-gpu/tests/local_contrast.rs#L467), [`core/dr-gpu/tests/noise_reduction.rs:513`](../core/dr-gpu/tests/noise_reduction.rs#L513), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/graph.rs:420`](../core/dr-pipeline/src/graph.rs#L420), [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318), [`core/dr-pipeline/src/operation.rs:31`](../core/dr-pipeline/src/operation.rs#L31), [`core/dr-pipeline/src/operation.rs:52`](../core/dr-pipeline/src/operation.rs#L52), [`core/dr-pipeline/src/operation.rs:70`](../core/dr-pipeline/src/operation.rs#L70) | +| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:121`](../core/dr-decode/src/lib.rs#L121), [`core/dr-decode/src/lib.rs:702`](../core/dr-decode/src/lib.rs#L702), [`core/dr-decode/src/lib.rs:742`](../core/dr-decode/src/lib.rs#L742), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:37`](../core/dr-gpu/src/adjust.rs#L37), [`core/dr-gpu/src/adjust.rs:690`](../core/dr-gpu/src/adjust.rs#L690), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1344`](../core/dr-pipeline/src/operation.rs#L1344), [`core/dr-pipeline/src/operation.rs:1369`](../core/dr-pipeline/src/operation.rs#L1369), [`core/dr-pipeline/src/operation.rs:1384`](../core/dr-pipeline/src/operation.rs#L1384), [`core/dr-pipeline/src/operation.rs:1405`](../core/dr-pipeline/src/operation.rs#L1405), [`core/dr-pipeline/src/operation.rs:369`](../core/dr-pipeline/src/operation.rs#L369), [`core/dr-pipeline/src/operation.rs:379`](../core/dr-pipeline/src/operation.rs#L379), [`core/dr-pipeline/src/operation.rs:513`](../core/dr-pipeline/src/operation.rs#L513) | +| FR-DEV-3h | [`core/dr-decode/src/lib.rs:401`](../core/dr-decode/src/lib.rs#L401), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:202`](../core/dr-pipeline/src/framing.rs#L202), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/lib.rs:217`](../core/dr-gpu/src/lib.rs#L217) | -| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2210`](../ui/dr-ui/src/develop.rs#L2210), [`ui/dr-ui/src/develop.rs:2220`](../ui/dr-ui/src/develop.rs#L2220), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/lib.rs:1111`](../ui/dr-ui/src/lib.rs#L1111) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:2171`](../ui/dr-ui/src/develop.rs#L2171), [`ui/dr-ui/src/develop.rs:2181`](../ui/dr-ui/src/develop.rs#L2181), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2372`](../ui/dr-ui/src/library_ui.rs#L2372), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1077`](../ui/dr-ui/ui/library.slint#L1077), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:737`](../ui/dr-ui/ui/library.slint#L737), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | -| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:333`](../core/dr-pipeline/src/detail.rs#L333), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1691`](../core/dr-gpu/src/adjust.rs#L1691), [`core/dr-gpu/src/adjust.rs:1768`](../core/dr-gpu/src/adjust.rs#L1768), [`core/dr-gpu/src/adjust.rs:1853`](../core/dr-gpu/src/adjust.rs#L1853), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:408`](../core/dr-pipeline/src/detail.rs#L408), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`ui/dr-ui/src/develop.rs:1738`](../ui/dr-ui/src/develop.rs#L1738), [`ui/dr-ui/src/develop.rs:2358`](../ui/dr-ui/src/develop.rs#L2358), [`ui/dr-ui/src/develop.rs:2730`](../ui/dr-ui/src/develop.rs#L2730), [`ui/dr-ui/src/develop.rs:2764`](../ui/dr-ui/src/develop.rs#L2764), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57), [`ui/dr-ui/src/lib.rs:665`](../ui/dr-ui/src/lib.rs#L665) | +| FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2253`](../ui/dr-ui/src/develop.rs#L2253), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/develop.rs:2263`](../ui/dr-ui/src/develop.rs#L2263), [`ui/dr-ui/src/lib.rs:1111`](../ui/dr-ui/src/lib.rs#L1111) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:2214`](../ui/dr-ui/src/develop.rs#L2214), [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2372`](../ui/dr-ui/src/library_ui.rs#L2372), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1077`](../ui/dr-ui/ui/library.slint#L1077), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:737`](../ui/dr-ui/ui/library.slint#L737), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | +| FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1772`](../core/dr-gpu/src/adjust.rs#L1772), [`core/dr-gpu/src/adjust.rs:1849`](../core/dr-gpu/src/adjust.rs#L1849), [`core/dr-gpu/src/adjust.rs:1934`](../core/dr-gpu/src/adjust.rs#L1934), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:204`](../core/dr-gpu/tests/capture_sharpen.rs#L204), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-gpu/tests/local_contrast.rs:254`](../core/dr-gpu/tests/local_contrast.rs#L254), [`core/dr-gpu/tests/noise_reduction.rs:365`](../core/dr-gpu/tests/noise_reduction.rs#L365), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:655`](../core/dr-pipeline/src/ops/local_contrast.rs#L655), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:1781`](../ui/dr-ui/src/develop.rs#L1781), [`ui/dr-ui/src/develop.rs:2401`](../ui/dr-ui/src/develop.rs#L2401), [`ui/dr-ui/src/develop.rs:2773`](../ui/dr-ui/src/develop.rs#L2773), [`ui/dr-ui/src/develop.rs:2807`](../ui/dr-ui/src/develop.rs#L2807), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57), [`ui/dr-ui/src/lib.rs:665`](../ui/dr-ui/src/lib.rs#L665) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | -| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1783`](../ui/dr-ui/src/develop.rs#L1783), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3832`](../ui/dr-ui/src/develop.rs#L3832), [`ui/dr-ui/src/develop.rs:3864`](../ui/dr-ui/src/develop.rs#L3864), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1170`](../ui/dr-ui/src/lib.rs#L1170), [`ui/dr-ui/src/lib.rs:283`](../ui/dr-ui/src/lib.rs#L283), [`ui/dr-ui/ui/app.slint:304`](../ui/dr-ui/ui/app.slint#L304), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | +| FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1826`](../ui/dr-ui/src/develop.rs#L1826), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/develop.rs:3907`](../ui/dr-ui/src/develop.rs#L3907), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1170`](../ui/dr-ui/src/lib.rs#L1170), [`ui/dr-ui/src/lib.rs:283`](../ui/dr-ui/src/lib.rs#L283), [`ui/dr-ui/ui/app.slint:304`](../ui/dr-ui/ui/app.slint#L304), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`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-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:137`](../core/dr-export/src/lib.rs#L137), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:50`](../core/dr-export/src/lib.rs#L50), [`core/dr-gpu/src/adjust.rs:2001`](../core/dr-gpu/src/adjust.rs#L2001), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:454`](../core/dr-types/src/settings.rs#L454), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2082`](../core/dr-gpu/src/adjust.rs#L2082), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:474`](../core/dr-types/src/settings.rs#L474), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`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-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.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-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:48`](../ui/dr-ui/src/settings_ui.rs#L48), [`ui/dr-ui/src/settings_ui.rs:561`](../ui/dr-ui/src/settings_ui.rs#L561) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:879`](../ui/dr-ui/src/export.rs#L879), [`ui/dr-ui/src/lib.rs:1694`](../ui/dr-ui/src/lib.rs#L1694), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326), [`ui/dr-ui/src/lib.rs:361`](../ui/dr-ui/src/lib.rs#L361), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5206`](../ui/dr-ui/src/library_ui.rs#L5206), [`ui/dr-ui/src/library_ui.rs:5283`](../ui/dr-ui/src/library_ui.rs#L5283), [`ui/dr-ui/src/library_ui.rs:5295`](../ui/dr-ui/src/library_ui.rs#L5295), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1243`](../ui/dr-ui/ui/app.slint#L1243), [`ui/dr-ui/ui/app.slint:932`](../ui/dr-ui/ui/app.slint#L932), [`ui/dr-ui/ui/library.slint:1085`](../ui/dr-ui/ui/library.slint#L1085), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) | -| FR-EXP-8 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`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:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-export/src/lib.rs:125`](../core/dr-export/src/lib.rs#L125), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:1861`](../ui/dr-ui/src/develop.rs#L1861), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`ui/dr-ui/src/lib.rs:1694`](../ui/dr-ui/src/lib.rs#L1694), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326), [`ui/dr-ui/src/lib.rs:361`](../ui/dr-ui/src/lib.rs#L361), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:469`](../ui/dr-ui/src/library_ui.rs#L469), [`ui/dr-ui/src/library_ui.rs:5206`](../ui/dr-ui/src/library_ui.rs#L5206), [`ui/dr-ui/src/library_ui.rs:5283`](../ui/dr-ui/src/library_ui.rs#L5283), [`ui/dr-ui/src/library_ui.rs:5295`](../ui/dr-ui/src/library_ui.rs#L5295), [`ui/dr-ui/src/library_ui.rs:540`](../ui/dr-ui/src/library_ui.rs#L540), [`ui/dr-ui/src/library_ui.rs:597`](../ui/dr-ui/src/library_ui.rs#L597), [`ui/dr-ui/ui/app.slint:1243`](../ui/dr-ui/ui/app.slint#L1243), [`ui/dr-ui/ui/app.slint:932`](../ui/dr-ui/ui/app.slint#L932), [`ui/dr-ui/ui/library.slint:1085`](../ui/dr-ui/ui/library.slint#L1085), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) | +| FR-EXP-8 | [`core/dr-decode/src/lib.rs:326`](../core/dr-decode/src/lib.rs#L326), [`core/dr-decode/src/lib.rs:350`](../core/dr-decode/src/lib.rs#L350), [`core/dr-decode/src/lib.rs:364`](../core/dr-decode/src/lib.rs#L364), [`core/dr-decode/src/lib.rs:71`](../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1163`](../core/dr-decode/src/locate.rs#L1163), [`core/dr-decode/src/locate.rs:1221`](../core/dr-decode/src/locate.rs#L1221), [`core/dr-decode/src/locate.rs:316`](../core/dr-decode/src/locate.rs#L316), [`core/dr-decode/src/locate.rs:487`](../core/dr-decode/src/locate.rs#L487), [`core/dr-decode/src/locate.rs:571`](../core/dr-decode/src/locate.rs#L571), [`core/dr-decode/src/locate.rs:584`](../core/dr-decode/src/locate.rs#L584), [`core/dr-decode/src/locate.rs:667`](../core/dr-decode/src/locate.rs#L667), [`core/dr-export/examples/export.rs:99`](../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:119`](../core/dr-export/src/encode.rs#L119), [`core/dr-export/src/encode.rs:163`](../core/dr-export/src/encode.rs#L163), [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:208`](../core/dr-export/src/encode.rs#L208), [`core/dr-export/src/encode.rs:237`](../core/dr-export/src/encode.rs#L237), [`core/dr-export/src/encode.rs:310`](../core/dr-export/src/encode.rs#L310), [`core/dr-export/src/encode.rs:324`](../core/dr-export/src/encode.rs#L324), [`core/dr-export/src/encode.rs:404`](../core/dr-export/src/encode.rs#L404), [`core/dr-export/src/encode.rs:452`](../core/dr-export/src/encode.rs#L452), [`core/dr-export/src/encode.rs:70`](../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:791`](../core/dr-export/src/encode.rs#L791), [`core/dr-export/src/encode.rs:805`](../core/dr-export/src/encode.rs#L805), [`core/dr-export/src/encode.rs:846`](../core/dr-export/src/encode.rs#L846), [`core/dr-export/src/encode.rs:894`](../core/dr-export/src/encode.rs#L894), [`core/dr-export/src/exif.rs:1`](../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:136`](../core/dr-export/src/lib.rs#L136), [`core/dr-export/src/metadata.rs:1`](../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:440`](../core/dr-types/src/lib.rs#L440), [`core/dr-types/src/settings.rs:192`](../core/dr-types/src/settings.rs#L192), [`ui/dr-ui/src/export.rs:620`](../ui/dr-ui/src/export.rs#L620), [`ui/dr-ui/src/export.rs:648`](../ui/dr-ui/src/export.rs#L648), [`ui/dr-ui/src/export.rs:776`](../ui/dr-ui/src/export.rs#L776), [`ui/dr-ui/src/export.rs:793`](../ui/dr-ui/src/export.rs#L793), [`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:503`](../core/dr-decode/src/lib.rs#L503), [`core/dr-export/src/lib.rs:128`](../core/dr-export/src/lib.rs#L128), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:791`](../core/dr-gpu/src/adjust.rs#L791), [`ui/dr-ui/src/develop.rs:1904`](../ui/dr-ui/src/develop.rs#L1904), [`ui/dr-ui/src/lib.rs:326`](../ui/dr-ui/src/lib.rs#L326) | | 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-10 | [`ui/dr-ui/src/export.rs:1`](../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:388`](../ui/dr-ui/src/lib.rs#L388), [`ui/dr-ui/src/library.rs:1512`](../ui/dr-ui/src/library.rs#L1512), [`ui/dr-ui/src/library.rs:398`](../ui/dr-ui/src/library.rs#L398), [`ui/dr-ui/src/library.rs:680`](../ui/dr-ui/src/library.rs#L680), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877), [`ui/dr-ui/src/library_ui.rs:1485`](../ui/dr-ui/src/library_ui.rs#L1485), [`ui/dr-ui/src/library_ui.rs:3093`](../ui/dr-ui/src/library_ui.rs#L3093), [`ui/dr-ui/src/library_ui.rs:410`](../ui/dr-ui/src/library_ui.rs#L410), [`ui/dr-ui/src/sidecar_cache.rs:1`](../ui/dr-ui/src/sidecar_cache.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:892`](../core/dr-sync-nextcloud/src/lib.rs#L892), [`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) | @@ -91,23 +91,23 @@ _None._ | FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:49`](../core/dr-types/src/lib.rs#L49) | | 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:222`](../core/dr-decode/src/lib.rs#L222), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) | -| FR-RAW-3 | [`core/dr-decode/src/lib.rs:118`](../core/dr-decode/src/lib.rs#L118), [`core/dr-decode/src/lib.rs:433`](../core/dr-decode/src/lib.rs#L433), [`core/dr-decode/src/locate.rs:1045`](../core/dr-decode/src/locate.rs#L1045) | +| FR-RAW-1 | [`core/dr-decode/src/lib.rs:243`](../core/dr-decode/src/lib.rs#L243), [`core/dr-types/src/lib.rs:125`](../core/dr-types/src/lib.rs#L125), [`core/dr-types/src/lib.rs:196`](../core/dr-types/src/lib.rs#L196) | +| FR-RAW-3 | [`core/dr-decode/src/lib.rs:139`](../core/dr-decode/src/lib.rs#L139), [`core/dr-decode/src/lib.rs:503`](../core/dr-decode/src/lib.rs#L503), [`core/dr-decode/src/locate.rs:1364`](../core/dr-decode/src/locate.rs#L1364) | | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1), [`ui/dr-ui/src/lib.rs:173`](../ui/dr-ui/src/lib.rs#L173) | -| FR-RAW-5 | [`core/dr-decode/src/lib.rs:146`](../core/dr-decode/src/lib.rs#L146), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | +| FR-RAW-5 | [`core/dr-decode/src/lib.rs:167`](../core/dr-decode/src/lib.rs#L167), [`core/dr-gpu/src/demosaic.rs:34`](../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:602`](../core/dr-gpu/src/demosaic.rs#L602), [`core/dr-gpu/src/demosaic.rs:681`](../core/dr-gpu/src/demosaic.rs#L681), [`core/dr-gpu/src/demosaic.rs:805`](../core/dr-gpu/src/demosaic.rs#L805) | | FR-UI-1 | [`ui/dr-ui/src/lib.rs:2276`](../ui/dr-ui/src/lib.rs#L2276), [`ui/dr-ui/src/lib.rs:65`](../ui/dr-ui/src/lib.rs#L65), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:844`](../ui/dr-ui/ui/library.slint#L844) | | FR-UI-2 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/lib.rs:65`](../ui/dr-ui/src/lib.rs#L65), [`ui/dr-ui/src/library_ui.rs:225`](../ui/dr-ui/src/library_ui.rs#L225), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/ui/app.slint:611`](../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/app.slint:618`](../ui/dr-ui/ui/app.slint#L618), [`ui/dr-ui/ui/library.slint:1000`](../ui/dr-ui/ui/library.slint#L1000), [`ui/dr-ui/ui/library.slint:1868`](../ui/dr-ui/ui/library.slint#L1868), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:987`](../ui/dr-ui/ui/library.slint#L987), [`ui/dr-ui/ui/library.slint:994`](../ui/dr-ui/ui/library.slint#L994) | -| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1449`](../ui/dr-ui/src/develop.rs#L1449), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:792`](../ui/dr-ui/src/masks_ui.rs#L792), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:792`](../ui/dr-ui/src/masks_ui.rs#L792), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:682`](../ui/dr-ui/ui/collections.slint#L682) | | FR-UI-4 | [`ui/dr-ui/src/collections_ui.rs:111`](../ui/dr-ui/src/collections_ui.rs#L111), [`ui/dr-ui/src/collections_ui.rs:124`](../ui/dr-ui/src/collections_ui.rs#L124), [`ui/dr-ui/src/collections_ui.rs:1448`](../ui/dr-ui/src/collections_ui.rs#L1448), [`ui/dr-ui/src/collections_ui.rs:1462`](../ui/dr-ui/src/collections_ui.rs#L1462), [`ui/dr-ui/src/collections_ui.rs:1507`](../ui/dr-ui/src/collections_ui.rs#L1507), [`ui/dr-ui/src/collections_ui.rs:1517`](../ui/dr-ui/src/collections_ui.rs#L1517), [`ui/dr-ui/src/collections_ui.rs:152`](../ui/dr-ui/src/collections_ui.rs#L152), [`ui/dr-ui/src/collections_ui.rs:1544`](../ui/dr-ui/src/collections_ui.rs#L1544), [`ui/dr-ui/src/collections_ui.rs:444`](../ui/dr-ui/src/collections_ui.rs#L444), [`ui/dr-ui/src/collections_ui.rs:472`](../ui/dr-ui/src/collections_ui.rs#L472), [`ui/dr-ui/src/collections_ui.rs:537`](../ui/dr-ui/src/collections_ui.rs#L537), [`ui/dr-ui/src/collections_ui.rs:941`](../ui/dr-ui/src/collections_ui.rs#L941), [`ui/dr-ui/src/collections_ui.rs:951`](../ui/dr-ui/src/collections_ui.rs#L951), [`ui/dr-ui/src/library_ui.rs:3754`](../ui/dr-ui/src/library_ui.rs#L3754), [`ui/dr-ui/src/library_ui.rs:3799`](../ui/dr-ui/src/library_ui.rs#L3799), [`ui/dr-ui/src/library_ui.rs:3902`](../ui/dr-ui/src/library_ui.rs#L3902), [`ui/dr-ui/src/library_ui.rs:3930`](../ui/dr-ui/src/library_ui.rs#L3930), [`ui/dr-ui/src/library_ui.rs:4500`](../ui/dr-ui/src/library_ui.rs#L4500), [`ui/dr-ui/src/library_ui.rs:4512`](../ui/dr-ui/src/library_ui.rs#L4512), [`ui/dr-ui/ui/app.slint:1468`](../ui/dr-ui/ui/app.slint#L1468), [`ui/dr-ui/ui/app.slint:587`](../ui/dr-ui/ui/app.slint#L587), [`ui/dr-ui/ui/app.slint:618`](../ui/dr-ui/ui/app.slint#L618), [`ui/dr-ui/ui/library.slint:1000`](../ui/dr-ui/ui/library.slint#L1000), [`ui/dr-ui/ui/library.slint:1868`](../ui/dr-ui/ui/library.slint#L1868), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:716`](../ui/dr-ui/ui/library.slint#L716), [`ui/dr-ui/ui/library.slint:987`](../ui/dr-ui/ui/library.slint#L987), [`ui/dr-ui/ui/library.slint:994`](../ui/dr-ui/ui/library.slint#L994) | | 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:1921`](../ui/dr-ui/src/lib.rs#L1921), [`ui/dr-ui/src/lib.rs:2310`](../ui/dr-ui/src/lib.rs#L2310), [`ui/dr-ui/src/lib.rs:2495`](../ui/dr-ui/src/lib.rs#L2495), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:325`](../ui/dr-ui/ui/app.slint#L325), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:100`](../core/dr-pipeline/src/descriptor.rs#L100), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1490`](../ui/dr-ui/src/export.rs#L1490), [`ui/dr-ui/src/export.rs:1516`](../ui/dr-ui/src/export.rs#L1516), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1728`](../ui/dr-ui/src/lib.rs#L1728), [`ui/dr-ui/ui/app.slint:943`](../ui/dr-ui/ui/app.slint#L943), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1552`](../ui/dr-ui/src/export.rs#L1552), [`ui/dr-ui/src/export.rs:1578`](../ui/dr-ui/src/export.rs#L1578), [`ui/dr-ui/src/export.rs:410`](../ui/dr-ui/src/export.rs#L410), [`ui/dr-ui/src/export.rs:436`](../ui/dr-ui/src/export.rs#L436), [`ui/dr-ui/src/lib.rs:1728`](../ui/dr-ui/src/lib.rs#L1728), [`ui/dr-ui/ui/app.slint:943`](../ui/dr-ui/ui/app.slint#L943), [`ui/dr-ui/ui/library.slint:752`](../ui/dr-ui/ui/library.slint#L752) | | NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`ui/dr-ui/src/export.rs:500`](../ui/dr-ui/src/export.rs#L500) | | 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), [`core/dr-catalog/src/walk.rs:162`](../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`tools/traceability/src/lib.rs:479`](../tools/traceability/src/lib.rs#L479) | | 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/export.rs:879`](../ui/dr-ui/src/export.rs#L879), [`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/export.rs:941`](../ui/dr-ui/src/export.rs#L941), [`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-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:265`](../core/dr-types/src/lib.rs#L265), [`core/dr-types/src/lib.rs:298`](../core/dr-types/src/lib.rs#L298) | | NFR-R1 | [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`ui/dr-ui/src/library.rs:877`](../ui/dr-ui/src/library.rs#L877) | | NFR-R2 | [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1) | From 35b126449b26a1f5379b0a896a1a8b7f0025cb17 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:32:59 +0200 Subject: [PATCH 24/27] Order the detail stage so the repair runs before the enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture sharpening and noise reduction were written in parallel and both claimed `order: 110`; the codegen refuses that, which is the guard working — two nodes at one order is an ambiguous pipeline and operation order changes the result. Resolved in noise reduction's favour, for the reason its own `placement:` block already gives: denoising is a repair and everything else in this stage is an enhancement. Sharpening or adding clarity to a noisy frame amplifies the grain along with the detail, and no later pass can separate them again. So the detail stage now runs noise reduction, capture sharpening, clarity, texture, and the other three shift up a slot to keep the multiple-of-ten convention the rest of the chain uses. Noise reduction was also missing the required `attributes:` key. The order collision aborted the build before the attribute check could report it, so it arrived looking like one fault and was two. --- core/dr-pipeline/ops/capture_sharpen.yaml | 2 +- core/dr-pipeline/ops/clarity.yaml | 2 +- core/dr-pipeline/ops/noise_reduction.yaml | 1 + core/dr-pipeline/ops/texture.yaml | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/core/dr-pipeline/ops/capture_sharpen.yaml b/core/dr-pipeline/ops/capture_sharpen.yaml index f135e7c..fa999e3 100644 --- a/core/dr-pipeline/ops/capture_sharpen.yaml +++ b/core/dr-pipeline/ops/capture_sharpen.yaml @@ -7,7 +7,7 @@ # from the type; this file exists so that `ops/` remains the one place the # pipeline's order is written down. id: capture_sharpen -order: 110 +order: 120 attributes: [detail] rust: CaptureSharpen diff --git a/core/dr-pipeline/ops/clarity.yaml b/core/dr-pipeline/ops/clarity.yaml index ea16cd2..eeab50d 100644 --- a/core/dr-pipeline/ops/clarity.yaml +++ b/core/dr-pipeline/ops/clarity.yaml @@ -4,7 +4,7 @@ # where the pipeline's order is written down, and an order kept half in YAML # and half in Rust would be worse than either alone. id: clarity -order: 120 +order: 130 attributes: [detail] rust: Clarity diff --git a/core/dr-pipeline/ops/noise_reduction.yaml b/core/dr-pipeline/ops/noise_reduction.yaml index 20722f0..e8b2040 100644 --- a/core/dr-pipeline/ops/noise_reduction.yaml +++ b/core/dr-pipeline/ops/noise_reduction.yaml @@ -8,6 +8,7 @@ id: noise_reduction order: 110 +attributes: [detail] rust: NoiseReduction why_rust: | diff --git a/core/dr-pipeline/ops/texture.yaml b/core/dr-pipeline/ops/texture.yaml index 6db3402..96aa49e 100644 --- a/core/dr-pipeline/ops/texture.yaml +++ b/core/dr-pipeline/ops/texture.yaml @@ -1,6 +1,6 @@ # A hand-written node — see `clarity.yaml`, whose implementation this shares. id: texture -order: 130 +order: 140 attributes: [detail] rust: Texture From ec4283e37f17373c7ac05f189e6efa8529ac3b9d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:34:24 +0200 Subject: [PATCH 25/27] Finish reconciling the whole-chain tests the three kernels each rewrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharpening, noise reduction and clarity were written in parallel and each rewrote the same two tests, which had counted one fused block per operation — true only while every operation was a point function. Kept the exclusive-or formulation: each operation must reach exactly one of the two stages. A count cannot tell "moved to the detail stage" from "vanished from both", and that ambiguity is what broke these tests three times over. The merge left two fragments of the versions it replaced — a loop over a set that no longer exists, and the tail of an assertion whose head was gone. The loop is not restored: `point ^ neighbourhood` already asserts per operation what it checked over the set. The assertion is, because it catches a different fault from the exclusive-or — a block in the shader that nothing in the chain asked for, rather than an operation in the wrong stage. --- core/dr-gpu/src/adjust.rs | 11 +++++++++++ core/dr-pipeline/src/lib.rs | 10 ---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/core/dr-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index d842a2d..becc922 100644 --- a/core/dr-gpu/src/adjust.rs +++ b/core/dr-gpu/src/adjust.rs @@ -1501,6 +1501,17 @@ mod tests { ); fused_blocks += usize::from(point); } + + // The count the loop above accumulated, plus framing — which emits a + // stage of its own rather than an operation block and is not in + // `descriptors`. Asserted as well as the per-operation exclusive-or + // because the two catch different faults: the XOR catches an operation + // in the wrong stage, this catches a block in the shader that nothing + // in the chain asked for. + assert_eq!( + shader.source.matches("---- ").count(), + fused_blocks + 1, + "the fused shader carries a block nothing in the chain asked for" ); assert!( shader.source.contains("---- framing ----"), diff --git a/core/dr-pipeline/src/lib.rs b/core/dr-pipeline/src/lib.rs index be7f1bd..2c23fb1 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -148,16 +148,6 @@ mod tests { fused_blocks, "the fused shader carries a block nothing in the chain asked for" ); - - // And each neighbourhood operation is genuinely absent from the fused - // shader rather than merely uncounted — the arithmetic above would be - // satisfied just as well by two errors that cancelled. - for id in &neighbourhood { - assert!( - !shader.source.contains(&format!("---- {id} ----")), - "{id} reads its neighbours and cannot be a fused fragment" - ); - } } #[test] From 36e03258d88ec39fce7ffc0b8cb3c4c275db750c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:36:34 +0200 Subject: [PATCH 26/27] Let texture on a thumbnail render, now that the seam it named is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `texture_contributes_nothing_where_its_scale_does_not_exist` asserted that the detail chain composed *nothing* when texture's kernel rounded away, and its comment recorded that empty chain as a gap: the fused pass had already decided to hand on linear working values, so an empty chain left the output transform undone and the render was rejected. It said fixing it meant composing both halves together, at the composition boundary rather than in that file. Noise reduction closed it there in the same round, by emitting a bodyless `detail/resolve` pass for exactly this case. So the assertion was describing a defect that no longer exists, and failing because the defect was fixed. Now asserts the property it was always about — texture contributes no kernel, `radius() == 0` — while the chain carries the one pass that finishes the render. Two agents working in parallel each saw one half of this; it is only visible with both merged. --- core/dr-gpu/tests/local_contrast.rs | 48 +++++++++++++++++------------ 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/core/dr-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs index b78e049..5b36bee 100644 --- a/core/dr-gpu/tests/local_contrast.rs +++ b/core/dr-gpu/tests/local_contrast.rs @@ -562,28 +562,38 @@ fn texture_contributes_nothing_where_its_scale_does_not_exist() { let mut graph = graph_with(TEXTURE, 100.0); // Asserted on the composed chain rather than on a dispatch counter, - // because texture *alone* at this size is a configuration the stage as a - // whole cannot currently render, and that is a gap in the seam rather than - // in this operation. + // because what is interesting here is not how many dispatches ran but + // that texture contributed no *kernel* to them. // - // `compose_full` decides whether the fused pass should hand on linear - // working values from `is_active()`, which has no `RenderScale` to consult; - // `compose_detail` decides what to dispatch from the kernel it can actually - // draw at this scale. Almost always the two agree. They disagree exactly - // when a detail operation is active and its kernel rounds away, and then - // `render_detailed` finds an empty chain, falls through to `render_masked`, - // and is rejected for handing a linear-working shader to the plain path. + // This assertion used to require an empty chain, and recorded the empty + // chain as a gap in the seam: `compose_full` decides whether the fused + // pass hands on linear working values from `is_active()`, which has no + // `RenderScale` to consult, while `compose_detail` decides what to + // dispatch from the kernel it can actually draw at this scale. When a + // detail operation was active and its kernel rounded away, the two + // disagreed, `render_detailed` found nothing to run, fell through to + // `render_masked`, and was rejected for handing a linear-working shader + // to the plain path — so texture alone on a thumbnail did not render. // - // Pre-existing, and not something clarity and texture introduce: - // `DetailStage::passes` documents the empty return as *the honest answer - // for an acutance operation on a heavy proxy*, so capture sharpening and - // noise reduction reach it by the same road. Fixing it means composing - // both halves of a render together, so the fused half can know whether a - // detail half survived the scale — a change at the composition boundary, - // not in this file. + // The seam was closed where that note said it would have to be, at the + // composition boundary: `compose_detail` now emits a bodyless + // `detail/resolve` pass in exactly this case, which reads only the pixel + // it writes and performs the output transform the fused pass declined to + // do. So the chain is no longer empty — it carries precisely the one pass + // that finishes the render and no kernel at all, which is the honest + // description of "a two-pixel surface structure is not present in a + // 128-pixel rendering". let scale = graph.render_scale(source.size(), (128, 128)); - assert!( - graph.compose_detail_for(scale, ColourSpace::Srgb).is_empty(), + let composed = graph.compose_detail_for(scale, ColourSpace::Srgb); + assert_eq!( + composed.len(), + 1, + "the chain must carry the resolve pass and nothing else" + ); + assert_eq!(composed.passes[0].label, "detail/resolve"); + assert_eq!( + composed.radius(), + 0, "texture claimed a kernel it cannot draw" ); From 42f1f55fd36b8b6fce5438ff55e9dc9cd230ada9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 19:37:56 +0200 Subject: [PATCH 27/27] Regenerate the traceability matrix The detail stage and its four kernels, the camera profiles, per-channel tone curves, keywords and export metadata all landed since the last regeneration. --- docs/traceability.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/traceability.md b/docs/traceability.md index e6a402a..2d91c3f 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -51,7 +51,7 @@ _None._ | 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:128`](../core/dr-pipeline/src/sidecar.rs#L128), [`ui/dr-ui/src/library.rs:179`](../ui/dr-ui/src/library.rs#L179), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340) | | FR-DEV-2 | [`core/dr-pipeline/src/operation.rs:318`](../core/dr-pipeline/src/operation.rs#L318) | -| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1849`](../core/dr-gpu/src/adjust.rs#L1849), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1298`](../ui/dr-ui/src/develop.rs#L1298), [`ui/dr-ui/src/develop.rs:1313`](../ui/dr-ui/src/develop.rs#L1313), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1418`](../ui/dr-ui/src/develop.rs#L1418), [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2595`](../ui/dr-ui/src/develop.rs#L2595), [`ui/dr-ui/src/develop.rs:2649`](../ui/dr-ui/src/develop.rs#L2649), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1119`](../ui/dr-ui/src/lib.rs#L1119), [`ui/dr-ui/src/lib.rs:1771`](../ui/dr-ui/src/lib.rs#L1771), [`ui/dr-ui/src/lib.rs:288`](../ui/dr-ui/src/lib.rs#L288), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772) | +| FR-DEV-3 | [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:395`](../core/dr-gpu/src/adjust.rs#L395), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:77`](../core/dr-gpu/src/adjust.rs#L77), [`core/dr-gpu/tests/tone_curve.rs:1`](../core/dr-gpu/tests/tone_curve.rs#L1), [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/framing.rs:188`](../core/dr-pipeline/src/framing.rs#L188), [`core/dr-pipeline/src/framing.rs:602`](../core/dr-pipeline/src/framing.rs#L602), [`core/dr-pipeline/src/graph.rs:121`](../core/dr-pipeline/src/graph.rs#L121), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/mask.rs:120`](../core/dr-pipeline/src/mask.rs#L120), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265), [`core/dr-pipeline/src/operation.rs:439`](../core/dr-pipeline/src/operation.rs#L439), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:207`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L207), [`core/dr-pipeline/src/ops/curve.rs:1`](../core/dr-pipeline/src/ops/curve.rs#L1), [`core/dr-pipeline/src/ops/curve.rs:223`](../core/dr-pipeline/src/ops/curve.rs#L223), [`core/dr-pipeline/src/ops/curve.rs:636`](../core/dr-pipeline/src/ops/curve.rs#L636), [`core/dr-pipeline/src/ops/curve.rs:99`](../core/dr-pipeline/src/ops/curve.rs#L99), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:1`](../core/dr-pipeline/src/ops/noise_reduction.rs#L1), [`core/dr-pipeline/src/ops/noise_reduction.rs:270`](../core/dr-pipeline/src/ops/noise_reduction.rs#L270), [`core/dr-pipeline/src/sidecar.rs:1276`](../core/dr-pipeline/src/sidecar.rs#L1276), [`core/dr-pipeline/src/sidecar.rs:1328`](../core/dr-pipeline/src/sidecar.rs#L1328), [`core/dr-pipeline/src/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`core/dr-pipeline/tests/tone_curve.rs:1`](../core/dr-pipeline/tests/tone_curve.rs#L1), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1298`](../ui/dr-ui/src/develop.rs#L1298), [`ui/dr-ui/src/develop.rs:1313`](../ui/dr-ui/src/develop.rs#L1313), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:1418`](../ui/dr-ui/src/develop.rs#L1418), [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2595`](../ui/dr-ui/src/develop.rs#L2595), [`ui/dr-ui/src/develop.rs:2649`](../ui/dr-ui/src/develop.rs#L2649), [`ui/dr-ui/src/develop.rs:276`](../ui/dr-ui/src/develop.rs#L276), [`ui/dr-ui/src/develop.rs:830`](../ui/dr-ui/src/develop.rs#L830), [`ui/dr-ui/src/lib.rs:1119`](../ui/dr-ui/src/lib.rs#L1119), [`ui/dr-ui/src/lib.rs:1771`](../ui/dr-ui/src/lib.rs#L1771), [`ui/dr-ui/src/lib.rs:288`](../ui/dr-ui/src/lib.rs#L288), [`ui/dr-ui/src/masks_ui.rs:217`](../ui/dr-ui/src/masks_ui.rs#L217), [`ui/dr-ui/src/masks_ui.rs:41`](../ui/dr-ui/src/masks_ui.rs#L41), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/src/masks_ui.rs:814`](../ui/dr-ui/src/masks_ui.rs#L814), [`ui/dr-ui/ui/app.slint:1772`](../ui/dr-ui/ui/app.slint#L1772) | | FR-DEV-3a | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/descriptor.rs:117`](../core/dr-pipeline/src/descriptor.rs#L117), [`core/dr-pipeline/src/descriptor.rs:157`](../core/dr-pipeline/src/descriptor.rs#L157), [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/descriptor.rs:232`](../core/dr-pipeline/src/descriptor.rs#L232), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:19`](../core/dr-pipeline/src/graph.rs#L19), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294), [`core/dr-pipeline/src/ops/curve.rs:323`](../core/dr-pipeline/src/ops/curve.rs#L323), [`ui/dr-ui/src/develop.rs:727`](../ui/dr-ui/src/develop.rs#L727), [`ui/dr-ui/src/lib.rs:524`](../ui/dr-ui/src/lib.rs#L524) | | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:259`](../core/dr-pipeline/src/framing.rs#L259), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:294`](../core/dr-pipeline/src/operation.rs#L294) | | FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:185`](../core/dr-pipeline/src/graph.rs#L185), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:950`](../core/dr-pipeline/src/mask.rs#L950), [`ui/dr-ui/src/develop.rs:3228`](../ui/dr-ui/src/develop.rs#L3228) | @@ -62,11 +62,11 @@ _None._ | FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:2253`](../ui/dr-ui/src/develop.rs#L2253), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/develop.rs:2263`](../ui/dr-ui/src/develop.rs#L2263), [`ui/dr-ui/src/lib.rs:1111`](../ui/dr-ui/src/lib.rs#L1111) | | FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:61`](../core/dr-types/src/settings.rs#L61), [`ui/dr-ui/src/develop.rs:2214`](../ui/dr-ui/src/develop.rs#L2214), [`ui/dr-ui/src/develop.rs:2224`](../ui/dr-ui/src/develop.rs#L2224), [`ui/dr-ui/src/lib.rs:1083`](../ui/dr-ui/src/lib.rs#L1083), [`ui/dr-ui/src/library.rs:1475`](../ui/dr-ui/src/library.rs#L1475), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library.rs:368`](../ui/dr-ui/src/library.rs#L368), [`ui/dr-ui/src/library_ui.rs:2372`](../ui/dr-ui/src/library_ui.rs#L2372), [`ui/dr-ui/src/library_ui.rs:2723`](../ui/dr-ui/src/library_ui.rs#L2723), [`ui/dr-ui/src/library_ui.rs:378`](../ui/dr-ui/src/library_ui.rs#L378), [`ui/dr-ui/src/presets.rs:1`](../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/settings_ui.rs:506`](../ui/dr-ui/src/settings_ui.rs#L506), [`ui/dr-ui/ui/adjust.slint:598`](../ui/dr-ui/ui/adjust.slint#L598), [`ui/dr-ui/ui/library.slint:1077`](../ui/dr-ui/ui/library.slint#L1077), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:737`](../ui/dr-ui/ui/library.slint#L737), [`ui/dr-ui/ui/settings.slint:79`](../ui/dr-ui/ui/settings.slint#L79) | | FR-DEV-8 | [`core/dr-pipeline/src/detail.rs:364`](../core/dr-pipeline/src/detail.rs#L364), [`core/dr-pipeline/src/operation.rs:265`](../core/dr-pipeline/src/operation.rs#L265) | -| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1772`](../core/dr-gpu/src/adjust.rs#L1772), [`core/dr-gpu/src/adjust.rs:1849`](../core/dr-gpu/src/adjust.rs#L1849), [`core/dr-gpu/src/adjust.rs:1934`](../core/dr-gpu/src/adjust.rs#L1934), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:204`](../core/dr-gpu/tests/capture_sharpen.rs#L204), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-gpu/tests/local_contrast.rs:254`](../core/dr-gpu/tests/local_contrast.rs#L254), [`core/dr-gpu/tests/noise_reduction.rs:365`](../core/dr-gpu/tests/noise_reduction.rs#L365), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:655`](../core/dr-pipeline/src/ops/local_contrast.rs#L655), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:1781`](../ui/dr-ui/src/develop.rs#L1781), [`ui/dr-ui/src/develop.rs:2401`](../ui/dr-ui/src/develop.rs#L2401), [`ui/dr-ui/src/develop.rs:2773`](../ui/dr-ui/src/develop.rs#L2773), [`ui/dr-ui/src/develop.rs:2807`](../ui/dr-ui/src/develop.rs#L2807), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57), [`ui/dr-ui/src/lib.rs:665`](../ui/dr-ui/src/lib.rs#L665) | +| FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:1783`](../core/dr-gpu/src/adjust.rs#L1783), [`core/dr-gpu/src/adjust.rs:1860`](../core/dr-gpu/src/adjust.rs#L1860), [`core/dr-gpu/src/adjust.rs:1945`](../core/dr-gpu/src/adjust.rs#L1945), [`core/dr-gpu/src/adjust.rs:506`](../core/dr-gpu/src/adjust.rs#L506), [`core/dr-gpu/src/adjust.rs:54`](../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/lib.rs:54`](../core/dr-gpu/src/lib.rs#L54), [`core/dr-gpu/src/lib.rs:94`](../core/dr-gpu/src/lib.rs#L94), [`core/dr-gpu/tests/capture_sharpen.rs:204`](../core/dr-gpu/tests/capture_sharpen.rs#L204), [`core/dr-gpu/tests/detail_stage.rs:322`](../core/dr-gpu/tests/detail_stage.rs#L322), [`core/dr-gpu/tests/local_contrast.rs:254`](../core/dr-gpu/tests/local_contrast.rs#L254), [`core/dr-gpu/tests/noise_reduction.rs:365`](../core/dr-gpu/tests/noise_reduction.rs#L365), [`core/dr-pipeline/src/detail.rs:136`](../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:439`](../core/dr-pipeline/src/detail.rs#L439), [`core/dr-pipeline/src/graph.rs:368`](../core/dr-pipeline/src/graph.rs#L368), [`core/dr-pipeline/src/graph.rs:394`](../core/dr-pipeline/src/graph.rs#L394), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:647`](../core/dr-pipeline/src/ops/capture_sharpen.rs#L647), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:655`](../core/dr-pipeline/src/ops/local_contrast.rs#L655), [`core/dr-pipeline/src/ops/noise_reduction.rs:691`](../core/dr-pipeline/src/ops/noise_reduction.rs#L691), [`ui/dr-ui/src/develop.rs:1781`](../ui/dr-ui/src/develop.rs#L1781), [`ui/dr-ui/src/develop.rs:2401`](../ui/dr-ui/src/develop.rs#L2401), [`ui/dr-ui/src/develop.rs:2773`](../ui/dr-ui/src/develop.rs#L2773), [`ui/dr-ui/src/develop.rs:2807`](../ui/dr-ui/src/develop.rs#L2807), [`ui/dr-ui/src/lib.rs:57`](../ui/dr-ui/src/lib.rs#L57), [`ui/dr-ui/src/lib.rs:665`](../ui/dr-ui/src/lib.rs#L665) | | FR-DSP-6 | [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1) | | FR-DSP-7 | [`core/dr-gpu/src/histogram.rs:147`](../core/dr-gpu/src/histogram.rs#L147), [`core/dr-gpu/src/histogram.rs:1`](../core/dr-gpu/src/histogram.rs#L1), [`core/dr-gpu/src/histogram.rs:281`](../core/dr-gpu/src/histogram.rs#L281), [`core/dr-gpu/src/histogram.rs:50`](../core/dr-gpu/src/histogram.rs#L50), [`core/dr-gpu/src/shaders/histogram.wgsl:1`](../core/dr-gpu/src/shaders/histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:1826`](../ui/dr-ui/src/develop.rs#L1826), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3875`](../ui/dr-ui/src/develop.rs#L3875), [`ui/dr-ui/src/develop.rs:3907`](../ui/dr-ui/src/develop.rs#L3907), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1170`](../ui/dr-ui/src/lib.rs#L1170), [`ui/dr-ui/src/lib.rs:283`](../ui/dr-ui/src/lib.rs#L283), [`ui/dr-ui/ui/app.slint:304`](../ui/dr-ui/ui/app.slint#L304), [`ui/dr-ui/ui/histogram.slint:122`](../ui/dr-ui/ui/histogram.slint#L122), [`ui/dr-ui/ui/histogram.slint:1`](../ui/dr-ui/ui/histogram.slint#L1) | | FR-EXP-1 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`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-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2082`](../core/dr-gpu/src/adjust.rs#L2082), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:474`](../core/dr-types/src/settings.rs#L474), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | +| FR-EXP-2 | [`core/dr-export/src/encode.rs:1`](../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/error.rs:26`](../core/dr-export/src/error.rs#L26), [`core/dr-export/src/icc.rs:1`](../core/dr-export/src/icc.rs#L1), [`core/dr-export/src/lib.rs:153`](../core/dr-export/src/lib.rs#L153), [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/lib.rs:53`](../core/dr-export/src/lib.rs#L53), [`core/dr-gpu/src/adjust.rs:2093`](../core/dr-gpu/src/adjust.rs#L2093), [`core/dr-pipeline/src/graph.rs:358`](../core/dr-pipeline/src/graph.rs#L358), [`core/dr-pipeline/src/graph.rs:404`](../core/dr-pipeline/src/graph.rs#L404), [`core/dr-pipeline/src/operation.rs:412`](../core/dr-pipeline/src/operation.rs#L412), [`core/dr-types/src/colour.rs:1`](../core/dr-types/src/colour.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`core/dr-types/src/settings.rs:474`](../core/dr-types/src/settings.rs#L474), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-3 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`core/dr-export/src/size.rs:25`](../core/dr-export/src/size.rs#L25), [`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-4 | [`core/dr-export/src/lib.rs:1`](../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |