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 208ac42..0f8c5b2 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 //! @@ -36,6 +37,7 @@ pub mod collections; pub mod dedup; pub mod error; pub mod jobs; +pub mod keywords; pub mod merge; pub mod query; pub mod rating; @@ -50,6 +52,7 @@ pub use collections::{Collection, CollectionKind, TreeRow}; pub use dedup::{seen_by_content, seen_by_metadata, set_content_hash}; 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/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/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"); 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, + /// 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/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 6495a33..9535a72 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,351 @@ 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(LATITUDE, LONGITUDE, 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) + } + + /// 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 + // 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 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!( + !contains_coordinate(&bytes, LATITUDE), + "{format:?} still contains the latitude" + ); + assert!( + !contains_coordinate(&bytes, LONGITUDE), + "{format:?} still contains the longitude" + ); + } + } + + #[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" + ); + // 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:?}"); + 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 - LATITUDE).abs() < 1e-5, "{format:?} {loc:?}"); + assert!((loc.longitude - LONGITUDE).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-gpu/src/adjust.rs b/core/dr-gpu/src/adjust.rs index df7c9d1..becc922 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; @@ -1063,12 +1068,39 @@ 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. + // + // Compiling only the fused half would leave every kernel + // 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 is + // converted the way a real render converts it. Mind the + // size: a radius stated in source pixels can decide there + // is nothing to draw at sixteen pixels + // (`RenderScale::resolves`) and compile its pass-through + // instead of the kernel under test. 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 +1454,84 @@ 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(); + // At 512 rather than the 32 this test used before the detail stage + // existed, and the size is load-bearing twice over. A compositional + // radius is a fraction of the frame, so on a 32-pixel target every + // detail kernel rounds to nothing: the chain would compose no passes, + // leaving half the shader uncompiled, and the exclusive-or below would + // find those operations in neither stage and fail for a reason that is + // not a defect. Shadows the smaller size deliberately. + 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" + ); + + // 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); + } + + // 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(), - // 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" + 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) + // 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. + 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/capture_sharpen.rs b/core/dr-gpu/tests/capture_sharpen.rs new file mode 100644 index 0000000..1b101ee --- /dev/null +++ b/core/dr-gpu/tests/capture_sharpen.rs @@ -0,0 +1,505 @@ +//! 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); + // 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. + // 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 + // 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-gpu/tests/local_contrast.rs b/core/dr-gpu/tests/local_contrast.rs new file mode 100644 index 0000000..5b36bee --- /dev/null +++ b/core/dr-gpu/tests/local_contrast.rs @@ -0,0 +1,617 @@ +//! 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(); + + // 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!( + 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); + + // Asserted on the composed chain rather than on a dispatch counter, + // because what is interesting here is not how many dispatches ran but + // that texture contributed no *kernel* to them. + // + // 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. + // + // 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)); + 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" + ); + + // 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(), + 2, + "clarity survives a thumbnail, and texture added nothing beside it" + ); + + // 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-gpu/tests/noise_reduction.rs b/core/dr-gpu/tests/noise_reduction.rs new file mode 100644 index 0000000..8ebf2f3 --- /dev/null +++ b/core/dr-gpu/tests/noise_reduction.rs @@ -0,0 +1,549 @@ +//! 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.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 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, + "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: 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 +/// 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; + 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 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 +/// 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. +/// +/// `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 value = sample(linear(pixels, width, x, row)); + let sign = if (x / half_period) % 2 == 0 { 1.0 } else { -1.0 }; + total += value * sign; + count += 1.0; + } + total / count +} + +/// 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() { + // 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. + // + // 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, 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, 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}" + ); +} + +#[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 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 + // 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, 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, chroma_r); + + // Both must be doing something: two flat images would agree perfectly and + // 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, chroma_r) + }; + assert!( + export_amp < untouched * 0.8, + "the denoiser did nothing: {export_amp} of {untouched}" + ); + + assert!( + (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" + ); + + // 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, chroma_r); + assert!( + wrong_amp < export_amp * 0.8, + "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-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 dfb8ec8..e4a10b0 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -209,9 +209,13 @@ 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), `capture_sharpen` (a separable convolution) and +`noise_reduction` (a kernel, and one that decides how many dispatches to emit +at each resolution) — the last two for the reason the next section gives. +`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 +243,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..fa999e3 --- /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: 120 + +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/ops/clarity.yaml b/core/dr-pipeline/ops/clarity.yaml new file mode 100644 index 0000000..eeab50d --- /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: 130 + +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/noise_reduction.yaml b/core/dr-pipeline/ops/noise_reduction.yaml new file mode 100644 index 0000000..e8b2040 --- /dev/null +++ b/core/dr-pipeline/ops/noise_reduction.yaml @@ -0,0 +1,31 @@ +# 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 + +attributes: [detail] +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/ops/texture.yaml b/core/dr-pipeline/ops/texture.yaml new file mode 100644 index 0000000..96aa49e --- /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: 140 + +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/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/detail.rs b/core/dr-pipeline/src/detail.rs index cccc909..003b191 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 @@ -449,6 +480,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 +534,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, @@ -536,7 +617,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 +667,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 +687,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 +975,87 @@ 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(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/lib.rs b/core/dr-pipeline/src/lib.rs index a763ef9..2c23fb1 100644 --- a/core/dr-pipeline/src/lib.rs +++ b/core/dr-pipeline/src/lib.rs @@ -107,12 +107,46 @@ 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. The XOR is + // the point: a plain count of fused blocks cannot tell "moved to the + // detail stage" from "vanished from both", and this test has now been + // broken three times by exactly that ambiguity. + // + // Composed at 1:1 deliberately. An acutance operation's radius is in + // source pixels, so on a proxy it may honestly decline to draw at all + // (`RenderScale::resolves`) — which would put it in neither half and + // make the assertion fail for a reason that is not a defect. + 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" ); } diff --git a/core/dr-pipeline/src/mask.rs b/core/dr-pipeline/src/mask.rs index 76c7d9c..e77e963 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(), } } @@ -934,9 +959,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 { @@ -1276,6 +1311,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); @@ -1625,4 +1703,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..d727a19 --- /dev/null +++ b/core/dr-pipeline/src/ops/capture_sharpen.rs @@ -0,0 +1,864 @@ +//! 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); + +// `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 +// 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(sharpened, 0.0) / max(centre, 1e-5)); +c = select(c, scaled, centre > 1e-5);"#; + +#[cfg(test)] +mod tests { + use super::*; + use crate::EditGraph; + use dr_types::ColourSpace; + + /// 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(graph: &EditGraph, scale: RenderScale) -> crate::detail::ComposedDetail { + graph.compose_detail_for(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(&EditGraph::default_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(&graph, scale).radius() as f32 / scale.ratio() + }; + + let export = in_source_pixels(4000); + let half = in_source_pixels(2000); + let fit = in_source_pixels(1600); + + 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 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!( + (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(&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] + 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 graph = sharpening(100.0, 1.0); + let proxy = RenderScale::new((1000, 1000), (4000, 4000)); + assert!(!proxy.resolves(1.0)); + + 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. + 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(&graph, 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 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); + 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 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 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. + 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 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 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!( + !code.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 + // 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/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/ops/local_contrast.rs b/core/dr-pipeline/src/ops/local_contrast.rs new file mode 100644 index 0000000..29de880 --- /dev/null +++ b/core/dr-pipeline/src/ops/local_contrast.rs @@ -0,0 +1,880 @@ +//! 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); + // 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!( + 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. + // 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() + .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. + // + // 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)))); + 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. + // + // 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()); + + // 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. + // + // 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); + 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. + // + // 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); + } + + #[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..1f00893 100644 --- a/core/dr-pipeline/src/ops/mod.rs +++ b/core/dr-pipeline/src/ops/mod.rs @@ -22,10 +22,27 @@ //! 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`] and [`noise_reduction`] read the pixels around the one +//! they write, so they run in [`crate::detail`]'s stage after the fused pass +//! rather than as fragments within it. They are ordinary +//! [`Operation`](crate::Operation)s in every other respect — descriptor, +//! parameters, sidecar, history — which is what lets the panel, the presets +//! and the undo stack carry them with no special case. +//! +//! [`noise_reduction`] shows why the declarative schema cannot express one at +//! all: a declared node's `wgsl:` is handed a colour with no way back to a +//! coordinate. A kernel decides at each render how many dispatches to emit, +//! and converts a radius stated in sensor pixels into the render pixels this +//! frame is actually 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 @@ -41,15 +58,23 @@ // 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 local_contrast; +pub mod noise_reduction; 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; +// 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 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/core/dr-pipeline/src/sidecar.rs b/core/dr-pipeline/src/sidecar.rs index 3b28fd8..a7b8d8f 100644 --- a/core/dr-pipeline/src/sidecar.rs +++ b/core/dr-pipeline/src/sidecar.rs @@ -1088,7 +1088,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(); @@ -1273,6 +1273,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" + ); + } + } +} + diff --git a/core/dr-types/src/lib.rs b/core/dr-types/src/lib.rs index 4b155fc..ed69ff5 100644 --- a/core/dr-types/src/lib.rs +++ b/core/dr-types/src/lib.rs @@ -439,6 +439,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 f102d5d..b5aaeea 100644 --- a/core/dr-types/src/settings.rs +++ b/core/dr-types/src/settings.rs @@ -255,6 +255,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 @@ -311,6 +330,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(), @@ -844,6 +864,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 diff --git a/docs/traceability.md b/docs/traceability.md index 11c832b..e5e516f 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 | 165 | -| TRACES tags found | 437 | +| Source files scanned | 180 | +| TRACES tags found | 516 | | Requirements defined | 177 | -| Requirements covered | 88 | -| **Coverage** | **49.7%** (88/177) | +| Requirements covered | 89 | +| **Coverage** | **50.3%** (89/177) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 70 | 122 | +| FR | 71 | 122 | | NFR | 16 | 49 | | R | 2 | 6 | @@ -34,101 +34,101 @@ _None._ | ID | Tagged in | |---|---| | 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:198`](../core/dr-types/src/lib.rs#L198), [`core/dr-types/src/lib.rs:267`](../core/dr-types/src/lib.rs#L267), [`core/dr-types/src/lib.rs:300`](../core/dr-types/src/lib.rs#L300), [`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-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:900`](../ui/dr-ui/src/lib.rs#L900), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:663`](../ui/dr-ui/ui/library.slint#L663), [`ui/dr-ui/ui/library.slint:802`](../ui/dr-ui/ui/library.slint#L802), [`ui/dr-ui/ui/library.slint:950`](../ui/dr-ui/ui/library.slint#L950) | -| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:900`](../ui/dr-ui/src/lib.rs#L900), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2052`](../ui/dr-ui/src/library.rs#L2052), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:692`](../ui/dr-ui/ui/library.slint#L692), [`ui/dr-ui/ui/library.slint:842`](../ui/dr-ui/ui/library.slint#L842), [`ui/dr-ui/ui/library.slint:990`](../ui/dr-ui/ui/library.slint#L990) | +| FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/src/library.rs:152`](../ui/dr-ui/src/library.rs#L152), [`ui/dr-ui/src/library.rs:2052`](../ui/dr-ui/src/library.rs#L2052), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:111`](../core/dr-pipeline/src/sidecar.rs#L111) | -| 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:124`](../core/dr-sync/src/lib.rs#L124), [`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:2933`](../ui/dr-ui/src/library.rs#L2933), [`ui/dr-ui/src/library.rs:2965`](../ui/dr-ui/src/library.rs#L2965), [`ui/dr-ui/src/library_ui.rs:128`](../ui/dr-ui/src/library_ui.rs#L128), [`ui/dr-ui/src/library_ui.rs:643`](../ui/dr-ui/src/library_ui.rs#L643), [`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-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:124`](../core/dr-sync/src/lib.rs#L124), [`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:2933`](../ui/dr-ui/src/library.rs#L2933), [`ui/dr-ui/src/library.rs:2965`](../ui/dr-ui/src/library.rs#L2965), [`ui/dr-ui/src/library_ui.rs:128`](../ui/dr-ui/src/library_ui.rs#L128), [`ui/dr-ui/src/library_ui.rs:643`](../ui/dr-ui/src/library_ui.rs#L643), [`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:51`](../core/dr-types/src/lib.rs#L51) | | 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), [`ui/dr-ui/src/import.rs:373`](../ui/dr-ui/src/import.rs#L373), [`ui/dr-ui/src/import.rs:398`](../ui/dr-ui/src/import.rs#L398), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3175`](../ui/dr-ui/src/library_ui.rs#L3175), [`ui/dr-ui/src/library_ui.rs:3949`](../ui/dr-ui/src/library_ui.rs#L3949), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| 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), [`ui/dr-ui/src/import.rs:373`](../ui/dr-ui/src/import.rs#L373), [`ui/dr-ui/src/import.rs:398`](../ui/dr-ui/src/import.rs#L398), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:115`](../ui/dr-ui/src/library_ui.rs#L115), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/src/library_ui.rs:4113`](../ui/dr-ui/src/library_ui.rs#L4113), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | 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: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) | -| 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:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:678`](../ui/dr-ui/ui/library.slint#L678), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697) | -| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:92`](../core/dr-pipeline/src/sidecar.rs#L92), [`ui/dr-ui/src/develop.rs:2030`](../ui/dr-ui/src/develop.rs#L2030), [`ui/dr-ui/src/export.rs:697`](../ui/dr-ui/src/export.rs#L697), [`ui/dr-ui/src/lib.rs:1124`](../ui/dr-ui/src/lib.rs#L1124), [`ui/dr-ui/src/lib.rs:1464`](../ui/dr-ui/src/lib.rs#L1464), [`ui/dr-ui/src/lib.rs:1569`](../ui/dr-ui/src/lib.rs#L1569), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:752`](../ui/dr-ui/src/lib.rs#L752), [`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:4238`](../ui/dr-ui/src/library_ui.rs#L4238), [`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:117`](../core/dr-types/src/lib.rs#L117), [`ui/dr-ui/src/develop.rs:1738`](../ui/dr-ui/src/develop.rs#L1738), [`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:3148`](../ui/dr-ui/src/library.rs#L3148), [`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:1450`](../ui/dr-ui/src/library_ui.rs#L1450), [`ui/dr-ui/src/library_ui.rs:1476`](../ui/dr-ui/src/library_ui.rs#L1476), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:1586`](../ui/dr-ui/src/library_ui.rs#L1586), [`ui/dr-ui/src/library_ui.rs:170`](../ui/dr-ui/src/library_ui.rs#L170), [`ui/dr-ui/src/library_ui.rs:203`](../ui/dr-ui/src/library_ui.rs#L203), [`ui/dr-ui/src/library_ui.rs:2119`](../ui/dr-ui/src/library_ui.rs#L2119), [`ui/dr-ui/src/library_ui.rs:2384`](../ui/dr-ui/src/library_ui.rs#L2384), [`ui/dr-ui/src/library_ui.rs:2566`](../ui/dr-ui/src/library_ui.rs#L2566), [`ui/dr-ui/src/library_ui.rs:2847`](../ui/dr-ui/src/library_ui.rs#L2847), [`ui/dr-ui/src/library_ui.rs:2919`](../ui/dr-ui/src/library_ui.rs#L2919), [`ui/dr-ui/src/library_ui.rs:3084`](../ui/dr-ui/src/library_ui.rs#L3084), [`ui/dr-ui/src/library_ui.rs:3202`](../ui/dr-ui/src/library_ui.rs#L3202), [`ui/dr-ui/src/library_ui.rs:358`](../ui/dr-ui/src/library_ui.rs#L358), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/library_ui.rs:4434`](../ui/dr-ui/src/library_ui.rs#L4434), [`ui/dr-ui/src/library_ui.rs:4549`](../ui/dr-ui/src/library_ui.rs#L4549), [`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: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:5391`](../ui/dr-ui/src/library_ui.rs#L5391), [`ui/dr-ui/src/library_ui.rs:5402`](../ui/dr-ui/src/library_ui.rs#L5402), [`ui/dr-ui/src/library_ui.rs:5415`](../ui/dr-ui/src/library_ui.rs#L5415), [`ui/dr-ui/src/library_ui.rs:5430`](../ui/dr-ui/src/library_ui.rs#L5430), [`ui/dr-ui/src/library_ui.rs:5439`](../ui/dr-ui/src/library_ui.rs#L5439), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600), [`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:736`](../ui/dr-ui/ui/library.slint#L736) | +| 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:600`](../ui/dr-ui/ui/app.slint#L600), [`ui/dr-ui/ui/library.slint:736`](../ui/dr-ui/ui/library.slint#L736) | +| 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:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | +| 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:1140`](../ui/dr-ui/src/lib.rs#L1140), [`ui/dr-ui/src/lib.rs:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/lib.rs:1585`](../ui/dr-ui/src/lib.rs#L1585), [`ui/dr-ui/src/lib.rs:464`](../ui/dr-ui/src/lib.rs#L464), [`ui/dr-ui/src/lib.rs:768`](../ui/dr-ui/src/lib.rs#L768), [`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:4402`](../ui/dr-ui/src/library_ui.rs#L4402), [`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:117`](../core/dr-types/src/lib.rs#L117), [`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:3148`](../ui/dr-ui/src/library.rs#L3148), [`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:1450`](../ui/dr-ui/src/library_ui.rs#L1450), [`ui/dr-ui/src/library_ui.rs:1476`](../ui/dr-ui/src/library_ui.rs#L1476), [`ui/dr-ui/src/library_ui.rs:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:1586`](../ui/dr-ui/src/library_ui.rs#L1586), [`ui/dr-ui/src/library_ui.rs:170`](../ui/dr-ui/src/library_ui.rs#L170), [`ui/dr-ui/src/library_ui.rs:203`](../ui/dr-ui/src/library_ui.rs#L203), [`ui/dr-ui/src/library_ui.rs:2119`](../ui/dr-ui/src/library_ui.rs#L2119), [`ui/dr-ui/src/library_ui.rs:2548`](../ui/dr-ui/src/library_ui.rs#L2548), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`ui/dr-ui/src/library_ui.rs:3011`](../ui/dr-ui/src/library_ui.rs#L3011), [`ui/dr-ui/src/library_ui.rs:3083`](../ui/dr-ui/src/library_ui.rs#L3083), [`ui/dr-ui/src/library_ui.rs:3248`](../ui/dr-ui/src/library_ui.rs#L3248), [`ui/dr-ui/src/library_ui.rs:3366`](../ui/dr-ui/src/library_ui.rs#L3366), [`ui/dr-ui/src/library_ui.rs:358`](../ui/dr-ui/src/library_ui.rs#L358), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`ui/dr-ui/src/library_ui.rs:4632`](../ui/dr-ui/src/library_ui.rs#L4632), [`ui/dr-ui/src/library_ui.rs:4747`](../ui/dr-ui/src/library_ui.rs#L4747), [`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), [`ui/dr-ui/src/import.rs:373`](../ui/dr-ui/src/import.rs#L373) | | 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-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/sidecar.rs:149`](../core/dr-pipeline/src/sidecar.rs#L149), [`ui/dr-ui/src/develop.rs:101`](../ui/dr-ui/src/develop.rs#L101), [`ui/dr-ui/src/develop.rs:1091`](../ui/dr-ui/src/develop.rs#L1091), [`ui/dr-ui/src/develop.rs:1106`](../ui/dr-ui/src/develop.rs#L1106), [`ui/dr-ui/src/develop.rs:1211`](../ui/dr-ui/src/develop.rs#L1211), [`ui/dr-ui/src/develop.rs:1285`](../ui/dr-ui/src/develop.rs#L1285), [`ui/dr-ui/src/develop.rs:132`](../ui/dr-ui/src/develop.rs#L132), [`ui/dr-ui/src/develop.rs:2388`](../ui/dr-ui/src/develop.rs#L2388), [`ui/dr-ui/src/develop.rs:243`](../ui/dr-ui/src/develop.rs#L243), [`ui/dr-ui/src/develop.rs:2442`](../ui/dr-ui/src/develop.rs#L2442), [`ui/dr-ui/src/lib.rs:1160`](../ui/dr-ui/src/lib.rs#L1160), [`ui/dr-ui/src/lib.rs:1812`](../ui/dr-ui/src/lib.rs#L1812), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`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:1856`](../ui/dr-ui/ui/app.slint#L1856) | -| 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), [`ui/dr-ui/src/lib.rs:526`](../ui/dr-ui/src/lib.rs#L526) | +| 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:1176`](../ui/dr-ui/src/lib.rs#L1176), [`ui/dr-ui/src/lib.rs:1828`](../ui/dr-ui/src/lib.rs#L1828), [`ui/dr-ui/src/lib.rs:290`](../ui/dr-ui/src/lib.rs#L290), [`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:1884`](../ui/dr-ui/ui/app.slint#L1884) | +| 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:526`](../ui/dr-ui/src/lib.rs#L526) | | 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:3021`](../ui/dr-ui/src/develop.rs#L3021) | -| 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:334`](../core/dr-types/src/lib.rs#L334) | +| 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:334`](../core/dr-types/src/lib.rs#L334) | | 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:2046`](../ui/dr-ui/src/develop.rs#L2046), [`ui/dr-ui/src/develop.rs:2056`](../ui/dr-ui/src/develop.rs#L2056), [`ui/dr-ui/src/develop.rs:225`](../ui/dr-ui/src/develop.rs#L225), [`ui/dr-ui/src/lib.rs:1152`](../ui/dr-ui/src/lib.rs#L1152) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:127`](../core/dr-types/src/settings.rs#L127), [`ui/dr-ui/src/develop.rs:2007`](../ui/dr-ui/src/develop.rs#L2007), [`ui/dr-ui/src/develop.rs:2017`](../ui/dr-ui/src/develop.rs#L2017), [`ui/dr-ui/src/lib.rs:1124`](../ui/dr-ui/src/lib.rs#L1124), [`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:2215`](../ui/dr-ui/src/library_ui.rs#L2215), [`ui/dr-ui/src/library_ui.rs:2566`](../ui/dr-ui/src/library_ui.rs#L2566), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384), [`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:562`](../ui/dr-ui/ui/adjust.slint#L562), [`ui/dr-ui/ui/library.slint:1065`](../ui/dr-ui/ui/library.slint#L1065), [`ui/dr-ui/ui/library.slint:640`](../ui/dr-ui/ui/library.slint#L640), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | -| 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:1574`](../ui/dr-ui/src/develop.rs#L1574), [`ui/dr-ui/src/develop.rs:2194`](../ui/dr-ui/src/develop.rs#L2194), [`ui/dr-ui/src/develop.rs:2566`](../ui/dr-ui/src/develop.rs#L2566), [`ui/dr-ui/src/develop.rs:2600`](../ui/dr-ui/src/develop.rs#L2600), [`ui/dr-ui/src/lib.rs:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:651`](../ui/dr-ui/src/lib.rs#L651) | +| 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:1168`](../ui/dr-ui/src/lib.rs#L1168) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../core/dr-pipeline/src/preset.rs#L1), [`core/dr-types/src/settings.rs:127`](../core/dr-types/src/settings.rs#L127), [`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:1140`](../ui/dr-ui/src/lib.rs#L1140), [`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:2379`](../ui/dr-ui/src/library_ui.rs#L2379), [`ui/dr-ui/src/library_ui.rs:2730`](../ui/dr-ui/src/library_ui.rs#L2730), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384), [`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:1105`](../ui/dr-ui/ui/library.slint#L1105), [`ui/dr-ui/ui/library.slint:666`](../ui/dr-ui/ui/library.slint#L666), [`ui/dr-ui/ui/library.slint:747`](../ui/dr-ui/ui/library.slint#L747), [`ui/dr-ui/ui/settings.slint:87`](../ui/dr-ui/ui/settings.slint#L87) | +| 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: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:59`](../ui/dr-ui/src/lib.rs#L59), [`ui/dr-ui/src/lib.rs:667`](../ui/dr-ui/src/lib.rs#L667) | | 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:1619`](../ui/dr-ui/src/develop.rs#L1619), [`ui/dr-ui/src/develop.rs:236`](../ui/dr-ui/src/develop.rs#L236), [`ui/dr-ui/src/develop.rs:3526`](../ui/dr-ui/src/develop.rs#L3526), [`ui/dr-ui/src/develop.rs:3558`](../ui/dr-ui/src/develop.rs#L3558), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/lib.rs:1211`](../ui/dr-ui/src/lib.rs#L1211), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:305`](../ui/dr-ui/ui/app.slint#L305), [`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:1227`](../ui/dr-ui/src/lib.rs#L1227), [`ui/dr-ui/src/lib.rs:285`](../ui/dr-ui/src/lib.rs#L285), [`ui/dr-ui/ui/app.slint:305`](../ui/dr-ui/ui/app.slint#L305), [`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:520`](../core/dr-types/src/settings.rs#L520), [`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:540`](../core/dr-types/src/settings.rs#L540), [`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:328`](../ui/dr-ui/src/lib.rs#L328), [`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:1735`](../ui/dr-ui/src/lib.rs#L1735), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:2936`](../ui/dr-ui/src/library_ui.rs#L2936), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:5175`](../ui/dr-ui/src/library_ui.rs#L5175), [`ui/dr-ui/src/library_ui.rs:5187`](../ui/dr-ui/src/library_ui.rs#L5187), [`ui/dr-ui/src/library_ui.rs:5199`](../ui/dr-ui/src/library_ui.rs#L5199), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:603`](../ui/dr-ui/src/library_ui.rs#L603), [`ui/dr-ui/ui/app.slint:1329`](../ui/dr-ui/ui/app.slint#L1329), [`ui/dr-ui/ui/app.slint:968`](../ui/dr-ui/ui/app.slint#L968), [`ui/dr-ui/ui/library.slint:1073`](../ui/dr-ui/ui/library.slint#L1073), [`ui/dr-ui/ui/library.slint:644`](../ui/dr-ui/ui/library.slint#L644), [`ui/dr-ui/ui/library.slint:722`](../ui/dr-ui/ui/library.slint#L722) | -| 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:1697`](../ui/dr-ui/src/develop.rs#L1697), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328) | +| 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:1751`](../ui/dr-ui/src/lib.rs#L1751), [`ui/dr-ui/src/lib.rs:175`](../ui/dr-ui/src/lib.rs#L175), [`ui/dr-ui/src/lib.rs:328`](../ui/dr-ui/src/lib.rs#L328), [`ui/dr-ui/src/lib.rs:363`](../ui/dr-ui/src/lib.rs#L363), [`ui/dr-ui/src/lib.rs:390`](../ui/dr-ui/src/lib.rs#L390), [`ui/dr-ui/src/library_ui.rs:3100`](../ui/dr-ui/src/library_ui.rs#L3100), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:5373`](../ui/dr-ui/src/library_ui.rs#L5373), [`ui/dr-ui/src/library_ui.rs:5450`](../ui/dr-ui/src/library_ui.rs#L5450), [`ui/dr-ui/src/library_ui.rs:5462`](../ui/dr-ui/src/library_ui.rs#L5462), [`ui/dr-ui/src/library_ui.rs:546`](../ui/dr-ui/src/library_ui.rs#L546), [`ui/dr-ui/src/library_ui.rs:603`](../ui/dr-ui/src/library_ui.rs#L603), [`ui/dr-ui/ui/app.slint:1353`](../ui/dr-ui/ui/app.slint#L1353), [`ui/dr-ui/ui/app.slint:992`](../ui/dr-ui/ui/app.slint#L992), [`ui/dr-ui/ui/library.slint:1113`](../ui/dr-ui/ui/library.slint#L1113), [`ui/dr-ui/ui/library.slint:670`](../ui/dr-ui/ui/library.slint#L670), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) | +| 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:442`](../core/dr-types/src/lib.rs#L442), [`core/dr-types/src/settings.rs:258`](../core/dr-types/src/settings.rs#L258), [`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:328`](../ui/dr-ui/src/lib.rs#L328) | | 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:390`](../ui/dr-ui/src/lib.rs#L390), [`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:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:2936`](../ui/dr-ui/src/library_ui.rs#L2936), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`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:390`](../ui/dr-ui/src/lib.rs#L390), [`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:1492`](../ui/dr-ui/src/library_ui.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3100`](../ui/dr-ui/src/library_ui.rs#L3100), [`ui/dr-ui/src/library_ui.rs:416`](../ui/dr-ui/src/library_ui.rs#L416), [`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:157`](../core/dr-sync/src/lib.rs#L157), [`core/dr-sync/src/lib.rs:40`](../core/dr-sync/src/lib.rs#L40), [`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.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3175`](../ui/dr-ui/src/library_ui.rs#L3175), [`ui/dr-ui/src/library_ui.rs:3949`](../ui/dr-ui/src/library_ui.rs#L3949), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | +| 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.rs:2528`](../ui/dr-ui/src/library.rs#L2528), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/src/library_ui.rs:4113`](../ui/dr-ui/src/library_ui.rs#L4113), [`ui/dr-ui/ui/app.slint:528`](../ui/dr-ui/ui/app.slint#L528), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316), [`ui/dr-ui/ui/settings.slint:72`](../ui/dr-ui/ui/settings.slint#L72) | | 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:157`](../core/dr-sync/src/lib.rs#L157), [`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), [`ui/dr-ui/src/import.rs:398`](../ui/dr-ui/src/import.rs#L398) | | 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:1495`](../ui/dr-ui/src/lib.rs#L1495), [`ui/dr-ui/src/lib.rs:2217`](../ui/dr-ui/src/lib.rs#L2217), [`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:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:1125`](../ui/dr-ui/src/library_ui.rs#L1125), [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180), [`ui/dr-ui/src/library_ui.rs:1298`](../ui/dr-ui/src/library_ui.rs#L1298), [`ui/dr-ui/src/library_ui.rs:1417`](../ui/dr-ui/src/library_ui.rs#L1417), [`ui/dr-ui/src/library_ui.rs:1895`](../ui/dr-ui/src/library_ui.rs#L1895), [`ui/dr-ui/src/library_ui.rs:211`](../ui/dr-ui/src/library_ui.rs#L211), [`ui/dr-ui/src/library_ui.rs:222`](../ui/dr-ui/src/library_ui.rs#L222), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:251`](../ui/dr-ui/src/library_ui.rs#L251), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:327`](../ui/dr-ui/src/library_ui.rs#L327), [`ui/dr-ui/src/library_ui.rs:369`](../ui/dr-ui/src/library_ui.rs#L369), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:4451`](../ui/dr-ui/src/library_ui.rs#L4451), [`ui/dr-ui/src/library_ui.rs:4469`](../ui/dr-ui/src/library_ui.rs#L4469), [`ui/dr-ui/src/library_ui.rs:4481`](../ui/dr-ui/src/library_ui.rs#L4481), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`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:2326`](../ui/dr-ui/ui/app.slint#L2326), [`ui/dr-ui/ui/app.slint:589`](../ui/dr-ui/ui/app.slint#L589), [`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:768`](../ui/dr-ui/ui/library.slint#L768) | +| 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:1511`](../ui/dr-ui/src/lib.rs#L1511), [`ui/dr-ui/src/lib.rs:2251`](../ui/dr-ui/src/lib.rs#L2251), [`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:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:1125`](../ui/dr-ui/src/library_ui.rs#L1125), [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180), [`ui/dr-ui/src/library_ui.rs:1298`](../ui/dr-ui/src/library_ui.rs#L1298), [`ui/dr-ui/src/library_ui.rs:1417`](../ui/dr-ui/src/library_ui.rs#L1417), [`ui/dr-ui/src/library_ui.rs:1895`](../ui/dr-ui/src/library_ui.rs#L1895), [`ui/dr-ui/src/library_ui.rs:211`](../ui/dr-ui/src/library_ui.rs#L211), [`ui/dr-ui/src/library_ui.rs:222`](../ui/dr-ui/src/library_ui.rs#L222), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:251`](../ui/dr-ui/src/library_ui.rs#L251), [`ui/dr-ui/src/library_ui.rs:317`](../ui/dr-ui/src/library_ui.rs#L317), [`ui/dr-ui/src/library_ui.rs:327`](../ui/dr-ui/src/library_ui.rs#L327), [`ui/dr-ui/src/library_ui.rs:369`](../ui/dr-ui/src/library_ui.rs#L369), [`ui/dr-ui/src/library_ui.rs:432`](../ui/dr-ui/src/library_ui.rs#L432), [`ui/dr-ui/src/library_ui.rs:463`](../ui/dr-ui/src/library_ui.rs#L463), [`ui/dr-ui/src/library_ui.rs:4649`](../ui/dr-ui/src/library_ui.rs#L4649), [`ui/dr-ui/src/library_ui.rs:4667`](../ui/dr-ui/src/library_ui.rs#L4667), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/src/library_ui.rs:475`](../ui/dr-ui/src/library_ui.rs#L475), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`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:2359`](../ui/dr-ui/ui/app.slint#L2359), [`ui/dr-ui/ui/app.slint:589`](../ui/dr-ui/ui/app.slint#L589), [`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:808`](../ui/dr-ui/ui/library.slint#L808) | | FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1180`](../ui/dr-ui/src/library_ui.rs#L1180) | | 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:117`](../core/dr-types/src/lib.rs#L117), [`core/dr-types/src/lib.rs:199`](../core/dr-types/src/lib.rs#L199), [`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:1024`](../ui/dr-ui/src/library_ui.rs#L1024), [`ui/dr-ui/src/library_ui.rs:951`](../ui/dr-ui/src/library_ui.rs#L951), [`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), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:3175`](../ui/dr-ui/src/library_ui.rs#L3175), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316) | -| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:900`](../ui/dr-ui/src/lib.rs#L900), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | -| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:900`](../ui/dr-ui/src/lib.rs#L900) | -| 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:1464`](../ui/dr-ui/src/lib.rs#L1464), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384) | -| 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: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-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), [`ui/dr-ui/src/library.rs:2554`](../ui/dr-ui/src/library.rs#L2554), [`ui/dr-ui/src/library_ui.rs:3339`](../ui/dr-ui/src/library_ui.rs#L3339), [`ui/dr-ui/ui/settings.slint:316`](../ui/dr-ui/ui/settings.slint#L316) | +| FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:62`](../core/dr-types/src/settings.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | +| FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:75`](../ui/dr-ui/src/import.rs#L75), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:916`](../ui/dr-ui/src/lib.rs#L916) | +| 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:1480`](../ui/dr-ui/src/lib.rs#L1480), [`ui/dr-ui/src/library.rs:340`](../ui/dr-ui/src/library.rs#L340), [`ui/dr-ui/src/library_ui.rs:384`](../ui/dr-ui/src/library_ui.rs#L384) | +| 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:51`](../core/dr-types/src/lib.rs#L51) | | 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:127`](../core/dr-types/src/lib.rs#L127), [`core/dr-types/src/lib.rs:198`](../core/dr-types/src/lib.rs#L198) | -| 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:127`](../core/dr-types/src/lib.rs#L127), [`core/dr-types/src/lib.rs:198`](../core/dr-types/src/lib.rs#L198) | +| 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:175`](../ui/dr-ui/src/lib.rs#L175) | -| 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:2299`](../ui/dr-ui/src/lib.rs#L2299), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:826`](../ui/dr-ui/ui/library.slint#L826) | -| 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:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:4481`](../ui/dr-ui/src/library_ui.rs#L4481), [`ui/dr-ui/ui/app.slint:600`](../ui/dr-ui/ui/app.slint#L600), [`ui/dr-ui/ui/app.slint:607`](../ui/dr-ui/ui/app.slint#L607), [`ui/dr-ui/ui/library.slint:1816`](../ui/dr-ui/ui/library.slint#L1816), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:678`](../ui/dr-ui/ui/library.slint#L678), [`ui/dr-ui/ui/library.slint:975`](../ui/dr-ui/ui/library.slint#L975), [`ui/dr-ui/ui/library.slint:982`](../ui/dr-ui/ui/library.slint#L982), [`ui/dr-ui/ui/library.slint:988`](../ui/dr-ui/ui/library.slint#L988) | -| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1285`](../ui/dr-ui/src/develop.rs#L1285), [`ui/dr-ui/src/library_ui.rs:3744`](../ui/dr-ui/src/library_ui.rs#L3744), [`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:1856`](../ui/dr-ui/ui/app.slint#L1856), [`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:3744`](../ui/dr-ui/src/library_ui.rs#L3744), [`ui/dr-ui/src/library_ui.rs:3789`](../ui/dr-ui/src/library_ui.rs#L3789), [`ui/dr-ui/src/library_ui.rs:3892`](../ui/dr-ui/src/library_ui.rs#L3892), [`ui/dr-ui/src/library_ui.rs:3920`](../ui/dr-ui/src/library_ui.rs#L3920), [`ui/dr-ui/src/library_ui.rs:4469`](../ui/dr-ui/src/library_ui.rs#L4469), [`ui/dr-ui/src/library_ui.rs:4481`](../ui/dr-ui/src/library_ui.rs#L4481), [`ui/dr-ui/ui/app.slint:1552`](../ui/dr-ui/ui/app.slint#L1552), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/app.slint:607`](../ui/dr-ui/ui/app.slint#L607), [`ui/dr-ui/ui/library.slint:1816`](../ui/dr-ui/ui/library.slint#L1816), [`ui/dr-ui/ui/library.slint:632`](../ui/dr-ui/ui/library.slint#L632), [`ui/dr-ui/ui/library.slint:678`](../ui/dr-ui/ui/library.slint#L678), [`ui/dr-ui/ui/library.slint:697`](../ui/dr-ui/ui/library.slint#L697), [`ui/dr-ui/ui/library.slint:975`](../ui/dr-ui/ui/library.slint#L975), [`ui/dr-ui/ui/library.slint:982`](../ui/dr-ui/ui/library.slint#L982), [`ui/dr-ui/ui/library.slint:988`](../ui/dr-ui/ui/library.slint#L988) | -| 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:1944`](../ui/dr-ui/src/lib.rs#L1944), [`ui/dr-ui/src/lib.rs:2333`](../ui/dr-ui/src/lib.rs#L2333), [`ui/dr-ui/src/lib.rs:2518`](../ui/dr-ui/src/lib.rs#L2518), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:326`](../ui/dr-ui/ui/app.slint#L326), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) | +| 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:2333`](../ui/dr-ui/src/lib.rs#L2333), [`ui/dr-ui/src/lib.rs:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/masks_ui.rs:700`](../ui/dr-ui/src/masks_ui.rs#L700), [`ui/dr-ui/ui/library.slint:866`](../ui/dr-ui/ui/library.slint#L866) | +| 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:67`](../ui/dr-ui/src/lib.rs#L67), [`ui/dr-ui/src/library_ui.rs:230`](../ui/dr-ui/src/library_ui.rs#L230), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/ui/app.slint:619`](../ui/dr-ui/ui/app.slint#L619), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:1900`](../ui/dr-ui/ui/library.slint#L1900), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707) | +| FR-UI-3 | [`ui/dr-ui/src/develop.rs:1492`](../ui/dr-ui/src/develop.rs#L1492), [`ui/dr-ui/src/library_ui.rs:3908`](../ui/dr-ui/src/library_ui.rs#L3908), [`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:1884`](../ui/dr-ui/ui/app.slint#L1884), [`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:3908`](../ui/dr-ui/src/library_ui.rs#L3908), [`ui/dr-ui/src/library_ui.rs:3953`](../ui/dr-ui/src/library_ui.rs#L3953), [`ui/dr-ui/src/library_ui.rs:4056`](../ui/dr-ui/src/library_ui.rs#L4056), [`ui/dr-ui/src/library_ui.rs:4084`](../ui/dr-ui/src/library_ui.rs#L4084), [`ui/dr-ui/src/library_ui.rs:4667`](../ui/dr-ui/src/library_ui.rs#L4667), [`ui/dr-ui/src/library_ui.rs:4679`](../ui/dr-ui/src/library_ui.rs#L4679), [`ui/dr-ui/ui/app.slint:1580`](../ui/dr-ui/ui/app.slint#L1580), [`ui/dr-ui/ui/app.slint:595`](../ui/dr-ui/ui/app.slint#L595), [`ui/dr-ui/ui/app.slint:626`](../ui/dr-ui/ui/app.slint#L626), [`ui/dr-ui/ui/library.slint:1015`](../ui/dr-ui/ui/library.slint#L1015), [`ui/dr-ui/ui/library.slint:1022`](../ui/dr-ui/ui/library.slint#L1022), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:1900`](../ui/dr-ui/ui/library.slint#L1900), [`ui/dr-ui/ui/library.slint:658`](../ui/dr-ui/ui/library.slint#L658), [`ui/dr-ui/ui/library.slint:707`](../ui/dr-ui/ui/library.slint#L707), [`ui/dr-ui/ui/library.slint:726`](../ui/dr-ui/ui/library.slint#L726) | +| 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:1978`](../ui/dr-ui/src/lib.rs#L1978), [`ui/dr-ui/src/lib.rs:2367`](../ui/dr-ui/src/lib.rs#L2367), [`ui/dr-ui/src/lib.rs:2552`](../ui/dr-ui/src/lib.rs#L2552), [`ui/dr-ui/src/masks_ui.rs:747`](../ui/dr-ui/src/masks_ui.rs#L747), [`ui/dr-ui/ui/app.slint:326`](../ui/dr-ui/ui/app.slint#L326), [`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:1769`](../ui/dr-ui/src/lib.rs#L1769), [`ui/dr-ui/ui/app.slint:979`](../ui/dr-ui/ui/app.slint#L979), [`ui/dr-ui/ui/library.slint:722`](../ui/dr-ui/ui/library.slint#L722) | +| 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:1785`](../ui/dr-ui/src/lib.rs#L1785), [`ui/dr-ui/ui/app.slint:1003`](../ui/dr-ui/ui/app.slint#L1003), [`ui/dr-ui/ui/library.slint:762`](../ui/dr-ui/ui/library.slint#L762) | | 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:267`](../core/dr-types/src/lib.rs#L267), [`core/dr-types/src/lib.rs:300`](../core/dr-types/src/lib.rs#L300) | | 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:59`](../ui/dr-ui/src/lib.rs#L59) | -| 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), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528) | +| 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), [`ui/dr-ui/src/library.rs:2528`](../ui/dr-ui/src/library.rs#L2528) | | 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:217`](../core/dr-gpu/src/lib.rs#L217) | ## Not yet tagged -89 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +88 of 177 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements -- FR-CAT-13 - FR-CAT-14 - FR-CULL-10 - FR-CULL-11 diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index 6863a65..95851c6 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -273,6 +273,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 { @@ -339,6 +353,7 @@ impl DevelopSession { active_mask: None, show_overlay: false, active_tab: None, + curve_channel: 0, } } @@ -349,8 +364,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), } } @@ -384,8 +403,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() @@ -494,8 +515,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. @@ -508,9 +534,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() { @@ -558,7 +591,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 @@ -671,8 +706,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. @@ -681,38 +784,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 @@ -733,21 +819,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; @@ -757,16 +919,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 { @@ -891,11 +1055,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, capture sharpening, 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 +1088,32 @@ 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, so a + // crop and a zoom are already accounted for, and zooming to 1:1 + // restores 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); + // 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 +1812,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 +1936,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 +1963,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)) @@ -3419,9 +3626,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() @@ -3429,7 +3636,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() @@ -3469,6 +3680,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/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, diff --git a/ui/dr-ui/src/labels.rs b/ui/dr-ui/src/labels.rs index 4d2f242..67ea829 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 @@ -54,6 +61,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 @@ -121,6 +141,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] diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 7c3f77e..846fcaf 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -585,8 +585,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; } @@ -1861,6 +1877,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/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs index d6292ae..06405dc 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. /// @@ -2124,6 +2124,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 @@ -4319,6 +4483,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 @@ -5184,6 +5382,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/adjust.slint b/ui/dr-ui/ui/adjust.slint index b73adb1..3e0fe78 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); } } } } @@ -825,9 +861,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); @@ -986,12 +1029,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 d5e8905..63c9727 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, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint"; import { GradientHandle, HandleRole, 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"; @@ -597,6 +597,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. @@ -705,9 +724,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) --- @@ -1380,6 +1404,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); @@ -2266,6 +2294,11 @@ in property panel-visible: true; enabled: root.adjust-enabled; scope: root.adjust-scope; 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); } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 1035ee7..2dabbe1 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(); @@ -704,6 +733,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 @@ -1133,6 +1173,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 @@ -1384,6 +1452,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(); } @@ -1460,6 +1536,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(); } @@ -1820,6 +1904,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; @@ -2518,4 +2606,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; } + } + } + } + } }