//! TRACES: FR-DEV-1 //! Sidecar serialisation — the edit graph as durable, mergeable data. //! //! # Generic, for the same reason the UI is generic //! //! `dr-ui` builds its panel by walking [`EditGraph::capabilities`] and never //! names an operation (FR-DEV-3c). This module does the same thing to the //! same list: it reads every parameter an operation *declares*, and writes //! the ones that differ from their default. No operation implements a //! serialisation method, and adding one needs no change here — the //! descriptor it already publishes for the UI is exactly the description the //! sidecar needs. //! //! That symmetry is the point. There is one place an operation says what its //! parameters are, and both the interface and the persistence layer read it. //! A third place would be a third thing to forget to update. //! //! # Only non-default values are written //! //! An operation at neutral contributes nothing to the file, which is what //! makes the format survive both directions of version skew: //! //! - **Reading an old sidecar in a new build.** An operation added since is //! simply absent, and absence means default, which means neutral. The //! image renders as it did. //! - **Reading a new sidecar in an old build.** An unknown operation's lines //! are preserved verbatim (see [`Version::unknown`]) and written back //! untouched, so a device running behind cannot silently destroy an edit it //! does not understand. //! //! The alternative — writing every parameter — would make every file grow //! with the operation count and would still not solve either case. //! //! # Why a flat text format rather than serde //! //! FR-NC-9 requires conflict merge **at the edit-graph node level**: a crop //! made on one device and an exposure change made on another must both //! survive. In this format a node *is* a line, keyed by `op.param`, so the //! merge is a key-wise comparison over two maps ([`Version::merge`]) rather //! than a tree diff. A nested document would need the same map built at merge //! time anyway. //! //! It also keeps this crate dependency-free, which is the property that lets //! the descriptor and codegen logic be tested without a device (ARCH §6.5a). //! //! # Shape //! //! ```text //! drsc 1 //! //! [version 8f04c0e2-1f9a-4a63-b0e9-3d1f5a0c77b1] //! name = Default //! default = 1 //! revision = 7 //! device = 3a1c5f80-9d2e-4b11-8c6a-0f7e2d4b9a35 //! modified = 1754697600 //! exposure.exposure = 0.75 //! framing.crop_w = 0.8 //! ``` //! //! Values are decimal floats; keys are `op_id.param_id`. Both come from the //! descriptors, so the file is readable by a human debugging an edit that //! went wrong — which is the case that matters, since sidecars are the //! authoritative store (ARCH §6.12) and the catalog is the disposable index. use std::collections::BTreeMap; use std::fmt; use std::fmt::Write as _; use crate::coverage::Coverage; use crate::graph::EditGraph; use crate::mask::{ Falloff, Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER, }; use crate::preset::Preset; use crate::spot::{Spot, SpotMode, SpotSet}; use crate::state::{EditState, FilmRebake}; /// Format version of the document itself. /// /// Bumped only for a change no reader could otherwise survive. Adding an /// operation is *not* such a change — that is what the non-default rule /// above buys — so this is expected to stay at 1 for a long time. pub const FORMAT_VERSION: u32 = 1; /// The file extension for a DarkRoom sidecar. pub const EXTENSION: &str = "drsc"; /// Highest star rating. Mirrors `dr_catalog::rating::MAX_RATING`; duplicated /// rather than shared because this crate deliberately depends on nothing. pub const MAX_RATING: u8 = 5; /// Highest flag code: 0 unflagged, 1 pick, 2 reject. pub const MAX_FLAG: u8 = 2; /// TRACES: FR-CAT-8 | FR-NC-8 /// One image's sidecar: a keyed set of versions. /// /// A set rather than a single graph because an image may carry several /// virtual copies (FR-CAT-12), and because version identity has to be part of /// the format for conflict merge to operate per-version (FR-NC-8). #[derive(Debug, Clone, PartialEq, Default)] pub struct Sidecar { /// Versions by uuid. Ordered, so writing the same state twice produces /// byte-identical output — which is what lets a caller skip an upload by /// comparing content rather than trusting a dirty flag. pub versions: BTreeMap, /// Lines from a `[version]` block whose keys this build did not /// recognise as `op.param`, and any unrecognised top-level lines. /// /// Preserved so a older build round-trips a newer file without loss. unknown_blocks: Vec, } /// TRACES: FR-DEV-3f /// The stock and paper a version names, without the tables they bake to. /// /// Re-exported rather than defined here: a sidecar is one of the things an /// edit is written to, not where an edit is defined. See [`crate::state`]. pub use crate::state::FilmRef; /// TRACES: FR-CAT-12 | FR-NC-8 /// One named edit variant. #[derive(Debug, Clone, PartialEq, Default)] pub struct Version { pub uuid: String, pub name: String, pub is_default: bool, /// TRACES: FR-DEV-5 /// The version this is a named snapshot of, if it is one. /// /// A snapshot *is* an edit state, which is exactly what a version stores, /// so it is stored as one: the parameters, the masks and their parts, the /// repairs and the film all arrive through the blocks that already carry /// them, and a merge keys on the uuid as it does for any other version. /// What sets a snapshot apart is only this pointer — it belongs to /// another version's history rather than standing beside it as a variant /// (FR-CAT-12), so a reader listing what a photograph *is* skips it, and /// a reader listing what one edit *was* finds it here. /// /// Written as `snapshot-of`. A build that predates the key reads the /// block as an ordinary named version and keeps it, which is the right /// failure: nothing is lost, and the newer build finds it as a snapshot /// again. pub snapshot_of: Option, /// Monotonic per-edit counter (FR-NC-8). /// /// The primary merge discriminator, ahead of [`Self::modified`]: a device /// with a skewed clock must not be able to overwrite real work simply by /// claiming a later timestamp. pub revision: u64, /// The device that last wrote this version (FR-NC-8). pub device: String, /// Unix seconds. Breaks exact `revision` ties only. pub modified: i64, /// TRACES: FR-CAT-5 | FR-CULL-4 /// Star rating, 0..=5. Zero means *unrated*, which is a state rather than /// a low score — it is what "filter to unjudged" selects. /// /// Stored here, not only in the catalog, because the catalog is a /// disposable index (ARCH §6.12): a photographer who culls 3,000 frames /// and then deletes the catalog must not lose that afternoon's work. This /// and [`Self::flag`] are the two fields that make a cull durable. /// /// Written as its own top-level key rather than as an `op.param` line /// because a rating is not an edit — it changes no pixel, and putting it /// in the parameter map would make it an operation the graph must own. pub rating: u8, /// The pick/reject axis, independent of [`Self::rating`]. /// /// `0` unflagged, `1` pick, `2` reject — matching the catalog's encoding, /// so a value moving between the two stores needs no translation table /// that could drift. pub flag: u8, /// The edit itself: `(op, param) -> value`, non-default values only. pub params: BTreeMap<(String, String), f32>, /// TRACES: FR-DEV-3 | FR-NC-9 /// The local adjustments. /// /// Written as its own `[mask]` blocks rather than folded into /// [`Self::params`], because a layer is not a scalar: it carries a /// selection, a geometry, and a chain of its own. Flattening it into /// dotted keys would encode a list of region ids as something like /// `m1.region.0 = 12`, which is neither readable nor mergeable — and /// per-field merge under FR-NC-9 is most of the reason the ids are stored /// as ids at all. pub masks: MaskStack, /// TRACES: FR-DEV-3f /// The film stock this version develops on, named by id. /// /// A top-level key rather than an `op.param` line, for the reason /// [`Self::rating`] is one and [`Self::masks`] are: a stock is not a /// scalar. It is a choice of material, and the numbers that render it are /// derived from the choice rather than being the choice. /// /// The **id, not an index**. Stocks are files that users add /// (`core/dr-film/profiles`), so an index would mean installing a profile /// silently changed which film every existing photograph was developed on. /// /// Only the names travel. Turning them back into tables needs the profile /// database, which this crate does not link, so [`Self::apply`] leaves the /// graph's film cleared and the caller re-bakes — see `EditGraph::set_film`. pub film: Option, /// TRACES: FR-DEV-8 | FR-NC-9 /// The repairs (`docs/spot-removal.md`). /// /// A line per spot, keyed `spot.`, rather than a block per spot as a /// mask gets: a spot is eight numbers, and sixty-four blocks would bury the /// rest of the file. A line *per spot* rather than one line for the set, /// because the line is the unit of merge and of a readable diff — the same /// reasoning [`write_strokes`] gives for a line per stroke. pub spots: SpotSet, /// Keys this build did not recognise, kept verbatim. /// /// An operation this build lacks would otherwise be deleted the moment an /// older device saved the file — silent data loss across a version skew, /// which for an authoritative store is the worst failure available. pub unknown: BTreeMap, } impl Version { /// A new version holding everything `graph`'s edit consists of. /// /// The destructuring is exhaustive on purpose — see [`crate::state`]. A /// new part of an edit must not reach the file only by somebody /// remembering to add a line here, which is how [`Self::update`] came to /// write the masks and forget the film. pub fn from_graph(uuid: impl Into, name: impl Into, graph: &EditGraph) -> Self { let EditState { params, masks, film, spots, } = graph.state(); let params = params.into_params(); let masks = (*masks).clone(); Self { uuid: uuid.into(), name: name.into(), is_default: false, snapshot_of: None, revision: 1, device: String::new(), modified: 0, // A new version is unjudged: the graph says nothing about whether // the photograph is any good, and inventing a rating here would // put every image at zero stars *deliberately* rather than leaving // it in the "not yet looked at" state a cull resumes from. rating: 0, flag: 0, params, masks, film, spots, unknown: BTreeMap::new(), } } /// Apply this version's edit to a graph, returning the film it still owes. /// /// Loading is a *replacement* rather than an overlay: a parameter absent /// from the file means default, and would otherwise silently inherit /// whatever the graph happened to hold. /// /// Unknown operations and parameters are skipped with a warning by /// [`EditGraph::set_param`], and values are clamped there, so a corrupt /// or newer file cannot reach a shader. /// /// The [`FilmRebake`] is not a new obligation — restoring a stock always /// needed the profile database this crate does not link (ARCH §6.5a), and /// callers were already doing it from a comment. It is the same debt made /// impossible to walk past. pub fn apply(&self, graph: &mut EditGraph) -> FilmRebake { // Reset first for the *viewport's* sake, and only that: `set_state` // deliberately preserves the view so an undo does not read as // navigation, whereas opening a photograph should show it fitted // rather than at the zoom the previous one was inspected at. graph.reset(); graph.set_state(&EditState { params: Preset::from_params(self.params.clone()), masks: std::sync::Arc::new(self.masks.clone()), film: self.film.clone(), spots: self.spots.clone(), }) } /// TRACES: FR-DEV-5 /// Whether this version is a named snapshot of another one. pub fn is_snapshot(&self) -> bool { self.snapshot_of.is_some() } /// Record `graph` into this version, bumping the revision. /// /// The revision bump is what makes this the write path rather than a /// setter: FR-NC-9 resolves conflicts by revision, so a local edit that /// did not bump it is a local edit a remote one will silently win. pub fn update(&mut self, graph: &EditGraph, device: &str, now: i64) { // Exhaustive, and this is the call site that proves why it has to be: // this method wrote the parameters, the masks and the repairs and // silently dropped the film, so saving an edit developed on a stock // lost the stock. Nothing here can be forgotten now without failing to // compile. let EditState { params, masks, film, spots, } = graph.state(); self.params = params.into_params(); self.masks = (*masks).clone(); self.film = film; self.spots = spots; self.revision = self.revision.saturating_add(1); self.device = device.to_string(); self.modified = now; } /// Merge the mask stacks, returning the layers that genuinely conflicted. /// /// Reported as `("mask", id)` so a caller surfacing conflicts can show /// them in the same list as contested parameters without needing a second /// channel for them. fn merge_masks( &mut self, remote: &Version, base: Option<&Version>, remote_wins: bool, ) -> Vec<(String, String)> { let empty = MaskStack::new(); let base_masks = base.map(|b| &b.masks).unwrap_or(&empty); let mut conflicts = Vec::new(); let ids: Vec = self .masks .layers() .iter() .chain(remote.masks.layers()) .map(|l| l.id.clone()) .collect::>() .into_iter() .collect(); for id in ids { let ours = self.masks.get(&id); let theirs = remote.masks.get(&id); let was = base_masks.get(&id); let we_changed = ours != was; let they_changed = theirs != was; match (we_changed, they_changed) { // Only they touched it: take theirs, including a deletion. (false, true) => match theirs { Some(layer) => self.put_mask(layer.clone()), None => { self.masks.remove(&id); } }, (true, true) if ours != theirs => { conflicts.push(("mask".to_string(), id.clone())); if remote_wins { match theirs { Some(layer) => self.put_mask(layer.clone()), None => { self.masks.remove(&id); } } } } _ => {} } } conflicts } /// TRACES: FR-DEV-8 | FR-NC-9 /// Merge the spot sets, returning the repairs that genuinely conflicted. /// /// By id, exactly as [`Self::merge_masks`] does, and for the same reason /// one level down: a repair made on the phone and a repair made on the /// desktop are different ids, so both survive and neither is a conflict. /// That is most of why [`crate::spot::Spot::derive_id`] hashes the position /// rather than counting — with counted ids the two would collide here and /// one would be lost. /// /// A spot *both* sides moved resolves wholesale to the higher revision. /// Half of one device's offset with the other's radius is a repair neither /// photographer made, and unlike a mask there is not even a case for /// interleaving: eight numbers describe one disc. fn merge_spots( &mut self, remote: &Version, base: Option<&Version>, remote_wins: bool, ) -> Vec<(String, String)> { let empty = SpotSet::new(); let base_spots = base.map(|b| &b.spots).unwrap_or(&empty); let mut conflicts = Vec::new(); let ids: Vec = self .spots .spots() .iter() .chain(remote.spots.spots()) .map(|s| s.id.clone()) .collect::>() .into_iter() .collect(); for id in ids { let ours = self.spots.get(&id); let theirs = remote.spots.get(&id); let was = base_spots.get(&id); let we_changed = ours != was; let they_changed = theirs != was; match (we_changed, they_changed) { // Only they touched it: take theirs, a deletion included. (false, true) => match theirs { Some(spot) => self.put_spot(spot.clone()), None => { self.spots.remove(&id); } }, (true, true) if ours != theirs => { conflicts.push(("spot".to_string(), id.clone())); if remote_wins { match theirs { Some(spot) => self.put_spot(spot.clone()), None => { self.spots.remove(&id); } } } } _ => {} } } conflicts } /// Replace a repair of the same id, or append it. /// /// Position is not merged, for the reason [`Self::put_mask`] gives and one /// more: the order only decides which of two *overlapping* repairs lands on /// top, and repairs that overlap are already a case the photographer will /// look at. fn put_spot(&mut self, spot: Spot) { match self.spots.get_mut(&spot.id) { Some(existing) => *existing = spot, None => { self.spots.place(spot); } } } /// Replace a layer of the same id, or append it. /// /// Position is not merged. Two devices that reordered the same stack have /// no combined order that is either one's, and layer order only decides /// which of two *overlapping* masks composites last — a much smaller /// wrong than losing a layer. fn put_mask(&mut self, layer: MaskLayer) { match self.masks.get_mut(&layer.id) { Some(existing) => *existing = layer, None => { self.masks.push(layer); } } } /// TRACES: FR-NC-9 /// Merge a remote version into this one at the node level. /// /// Disjoint edits both survive: a crop made on one device and an exposure /// change made on the other are different keys, so neither is a conflict /// and the result carries both. Only a key *both* sides changed is /// ambiguous, and those resolve wholesale to the higher revision — not /// per key, because two values of the same parameter cannot be combined /// into a third that either user intended. /// /// Returns the keys that genuinely conflicted, so a caller can surface /// them (FR-NC-9: "only genuinely ambiguous merges surface to the UI"). pub fn merge(&mut self, remote: &Version, base: Option<&Version>) -> Vec<(String, String)> { let empty = BTreeMap::new(); let base_params = base.map(|b| &b.params).unwrap_or(&empty); let changed = |side: &BTreeMap<(String, String), f32>, key: &(String, String)| { side.get(key) != base_params.get(key) }; // The remote wins ties by revision, then by timestamp. Computed once: // applying it per key would let a single merge take some keys from // each side, producing a state neither device ever had. let remote_wins = (remote.revision, remote.modified) > (self.revision, self.modified); let mut conflicts = Vec::new(); let keys: Vec<(String, String)> = self .params .keys() .chain(remote.params.keys()) .chain(base_params.keys()) .cloned() .collect::>() .into_iter() .collect(); for key in keys { let ours = changed(&self.params, &key); let theirs = changed(&remote.params, &key); match (ours, theirs) { // Only they touched it — take theirs. This is the disjoint // case, and the whole reason the merge is key-wise. (false, true) => match remote.params.get(&key) { Some(v) => { self.params.insert(key, *v); } None => { self.params.remove(&key); } }, // Both touched it, to different values: genuinely ambiguous. (true, true) if self.params.get(&key) != remote.params.get(&key) => { conflicts.push(key.clone()); if remote_wins { match remote.params.get(&key) { Some(v) => { self.params.insert(key, *v); } None => { self.params.remove(&key); } } } } // Only we touched it, or both landed on the same value. _ => {} } } // Judgement is not a parameter and is not merged key-wise: a rating is // a single scalar, so there is no disjoint case to preserve — two // devices that both rated a frame simply disagree, and the higher // revision is the answer, exactly as for a contested parameter. // // The asymmetry with `params` is deliberate. A device that has *not* // rated a frame holds 0, which is indistinguishable from "rated // zero", so treating the remote's 0 as an edit would let an // un-culled device silently wipe the ratings of a culled one. Taking // a non-zero remote value when we hold none is the safe direction: // a judgement can be added across devices but never erased by one // that never had it. self.rating = merge_judgement(self.rating, remote.rating, remote_wins); self.flag = merge_judgement(self.flag, remote.flag, remote_wins); // TRACES: FR-DEV-3f // The film resolves wholesale to the higher revision, like a mask // layer and unlike a parameter. It is one decision with two names in // it: taking the stock from one device and the paper from the other // would print a negative on a paper nobody chose it for, which is a // combination neither photographer asked for and which renders as a // colour cast rather than as an obvious mistake. // // Unlike a rating, a cleared film *is* an edit — "develop this // normally again" — so `None` propagates where a zero rating does not. // The revision is what says whether it was cleared or never set. if remote_wins { self.film = remote.film.clone(); } // Masks merge by layer id, which is the same disjoint-survives rule // the parameters follow one level up: a layer added on the phone and // a layer added on the desktop are different ids, so both survive and // neither is a conflict. // // A layer *both* sides edited resolves wholesale to the higher // revision rather than field by field. Two people's versions of one // mask cannot be interleaved into a third — half of one selection // plus half of another's opacity is a layer neither of them made — // so the layer is the unit, exactly as the value is for a parameter. conflicts.extend(self.merge_masks(remote, base, remote_wins)); conflicts.extend(self.merge_spots(remote, base, remote_wins)); // Unknown keys follow the same rule, so an operation neither side // understands is not dropped by the merge either. for (k, v) in &remote.unknown { self.unknown.entry(k.clone()).or_insert_with(|| v.clone()); } // The merged result is newer than either input, or the next write // would look stale to a device that has already seen the remote. self.revision = self.revision.max(remote.revision).saturating_add(1); self.modified = self.modified.max(remote.modified); conflicts } } /// Resolve one judgement scalar — a rating or a flag — across two devices. /// /// Zero carries no information here. A device that has never judged a frame /// holds 0, and that is indistinguishable from a deliberate "back to /// unrated", so the two cases cannot be told apart from the value alone. The /// resolution follows from which mistake is worse: /// /// - Taking a remote judgement when we have none **adds** work that was /// genuinely done elsewhere. If it was wrong, the user re-presses a key. /// - Taking a remote zero when we have a rating **erases** an afternoon of /// culling, silently, on a device that was never involved. /// /// So a zero never overwrites a judgement; a real judgement overwrites ours /// only when the remote also wins on revision. The cost is that clearing a /// rating does not propagate — pressing `0` on one device leaves the other /// device's star standing. That is the deliberate trade, and it is the same /// direction of caution the merge takes everywhere else. fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 { match (ours, theirs) { // Nothing to lose: any real remote judgement is strictly more // information than we hold. (0, t) => t, // We hold one and they hold none — theirs says nothing. (o, 0) => o, // Both judged. A genuine disagreement, resolved by revision like any // other contested value. (o, t) => { if remote_wins { t } else { o } } } } impl Sidecar { pub fn new() -> Self { Self::default() } /// TRACES: FR-NC-8 | FR-NC-9 /// The version marked default, or the newest if none is. /// /// # Why this compares revisions rather than taking the first /// /// This used to answer with the first `is_default` in map order, and map /// order is *uuid* order. That is only ever right when exactly one version /// claims to be the default, and two devices editing one photograph /// routinely produce two that do — see [`Self::fuse_default_versions`] for /// how they come to exist. With two, the answer became a per-photograph /// coin toss decided by which random uuid happened to sort lower, and the /// losing device's afternoon of work simply did not appear. /// /// So the same discriminator the merge uses decides it here: `revision` /// first, `modified` only to break an exact tie. A device with a skewed /// clock cannot win by claiming a later timestamp (FR-NC-8), and the /// answer no longer depends on how a uuid sorts. /// /// This is a *reader's* resolution and deliberately not a merge: it picks /// a version, it does not combine two. Anything that goes on to write the /// file must fuse first, or the edit it did not pick is still sitting /// there to be picked next time. pub fn default_version(&self) -> Option<&Version> { self.versions .values() .filter(|v| v.is_default) .max_by_key(|v| (v.revision, v.modified)) // Never a snapshot: a file with no default and a snapshot in it // is a file whose edit is *missing*, and answering with a saved // state of it would open the photograph at a moment the // photographer deliberately stepped away from. .or_else(|| self.versions.values().find(|v| !v.is_snapshot())) } /// TRACES: FR-DEV-5 /// The named snapshots of one version, oldest first. /// /// Taken time is `modified`, so the order is the order they were taken in /// whichever device took them; the uuid breaks a tie so the list reads /// the same on every device. pub fn snapshots_of(&self, uuid: &str) -> Vec<&Version> { let mut out: Vec<&Version> = self .versions .values() .filter(|v| v.snapshot_of.as_deref() == Some(uuid)) .collect(); out.sort_by(|a, b| (a.modified, &a.uuid).cmp(&(b.modified, &b.uuid))); out } /// TRACES: FR-DEV-5 | FR-NC-9 /// Write a session's snapshots of `uuid` into the file. /// /// `removed` are the ones the session deleted, taken out by id; /// `snapshots` are the ones it holds, put in. A snapshot in the file that /// is in neither — one another device took since this session opened /// the photograph — is left standing, which is the same rule /// [`Version::merge`] keeps for a version only one side has: never treat /// "I did not see it" as "I removed it". /// /// Each snapshot is re-pointed at `uuid` on the way in, because the /// default version may have been fused onto a canonical identity since /// the snapshot was taken, and a snapshot of a uuid nothing carries is /// a snapshot of nothing. pub fn replace_snapshots(&mut self, uuid: &str, snapshots: Vec, removed: &[String]) { for id in removed { self.versions.remove(id); } for mut snapshot in snapshots { snapshot.snapshot_of = Some(uuid.to_string()); snapshot.is_default = false; self.put(snapshot); } } /// TRACES: FR-NC-8 | FR-NC-9 /// Collapse every version claiming to be the default onto one identity. /// /// # The failure this repairs /// /// A version's uuid is the identity a cross-device merge keys on, and for /// a long time each device minted its own at random for the same /// photograph. Two devices editing one frame therefore wrote two /// `[version]` blocks, both marked `default = 1`, and /// [`Version::merge`] — which is perfectly capable of combining them — /// was never handed the pair, because it matches on uuid and the uuids /// never matched. The two edits simply accumulated, and whichever one /// [`Self::default_version`] happened to pick was the only one anybody /// saw. /// /// Minting the uuid deterministically stops new splits. It does nothing /// for the files already written, which is what this is for. /// /// # Why folding into the winner rather than merging in sequence /// /// [`Version::merge`] raises its own `revision` to `max + 1` as it goes, /// so merging a chain in ascending order stops being ascending after the /// first step and the third version would lose to the accumulator. Folding /// *into* the highest `(revision, modified)` avoids the question: every /// other version is merged in as the remote and loses every contested /// value, while the disjoint case — a key only it holds — is taken /// regardless of who wins, which is the whole point of a key-wise merge. /// Ratings come across under [`merge_judgement`], so a device that never /// judged the frame cannot erase one that did. /// /// The result is the same on every device, because it is a function of the /// file's contents alone. Two devices that fuse independently and upload /// converge rather than ping-ponging. /// /// # `canonical` /// /// The uuid the result should carry — the caller's derived identity for /// this photograph (`dr_catalog::rating::derived_version_uuid`). Passing /// `None` folds onto the lexicographically smallest of the defaults, which /// is still device-independent and is the rule /// `dr_catalog::keywords::fuse_duplicates` already uses for the same shape /// of problem. /// /// A rename happens even when there is nothing to fuse: a lone default /// still sitting under a randomly minted uuid has to move to the derived /// one, or the next write finds no match and adds a *third* version. /// /// Returns the uuid the default now carries, or `None` if there was no /// default version to fuse. pub fn fuse_default_versions(&mut self, canonical: Option<&str>) -> Option { let mut defaults: Vec = self .versions .values() .filter(|v| v.is_default) .map(|v| v.uuid.clone()) .collect(); defaults.sort(); let winner_uuid = self .versions .values() .filter(|v| v.is_default) .max_by_key(|v| (v.revision, v.modified)) .map(|v| v.uuid.clone())?; // A canonical uuid that already names a *different*, non-default // version is a virtual copy (FR-CAT-12), and renaming onto it would // delete it. Vanishingly unlikely — a derived uuid is structurally // distinct from a randomly minted one — but silently destroying a // named edit is precisely the loss this module exists to prevent, so // the rename is declined rather than risked. let target = match canonical { Some(c) if !defaults.iter().any(|u| u == c) && self.versions.contains_key(c) => { log::warn!( "not renaming the default version onto {c}: another version already has it" ); winner_uuid.clone() } Some(c) => c.to_string(), None => defaults .first() .cloned() .unwrap_or_else(|| winner_uuid.clone()), }; let mut winner = self.versions.remove(&winner_uuid)?; for uuid in defaults.iter().filter(|u| **u != winner_uuid) { if let Some(loser) = self.versions.remove(uuid) { winner.merge(&loser, None); } } winner.uuid = target.clone(); self.versions.insert(target.clone(), winner); Some(target) } /// Insert or replace a version. pub fn put(&mut self, version: Version) { self.versions.insert(version.uuid.clone(), version); } /// Serialise to the on-disk form. /// /// Deterministic: the same state always produces the same bytes, so a /// caller may compare content to decide whether an upload is needed. pub fn to_text(&self) -> String { let mut out = format!("drsc {FORMAT_VERSION}\n"); for block in &self.unknown_blocks { let _ = writeln!(out, "{block}"); } for v in self.versions.values() { let _ = write!(out, "\n[version {}]\n", v.uuid); let _ = writeln!(out, "name = {}", v.name); if v.is_default { let _ = writeln!(out, "default = 1"); } // TRACES: FR-DEV-5 if let Some(of) = &v.snapshot_of { let _ = writeln!(out, "snapshot-of = {of}"); } let _ = writeln!(out, "revision = {}", v.revision); if !v.device.is_empty() { let _ = writeln!(out, "device = {}", v.device); } let _ = writeln!(out, "modified = {}", v.modified); // Judgement, written only when there is one. An unrated, // unflagged frame contributes nothing — the same non-default rule // the parameters follow, so a library that has never been culled // does not grow a line per file. if v.rating > 0 { let _ = writeln!(out, "rating = {}", v.rating); } if v.flag > 0 { let _ = writeln!(out, "flag = {}", v.flag); } // TRACES: FR-DEV-3f // Before the parameters, because it decides what they mean: the // film's exposure slider is a slider on *that stock's* curve. if let Some(film) = &v.film { let _ = writeln!(out, "film = {}", film.stock); if let Some(print) = &film.print { let _ = writeln!(out, "film_print = {print}"); } } for ((op, param), value) in &v.params { let _ = writeln!(out, "{op}.{param} = {}", format_value(*value)); } // TRACES: FR-DEV-8 // In the order they were made, which is the order they are drawn // in and the order that decides which repairs share a pass // (`SpotSet::rounds`). A `BTreeMap` here — sorting them by id — // would look tidier in the file and would silently reorder the // photograph. for spot in v.spots.spots() { write_spot(&mut out, spot); } for (k, raw) in &v.unknown { let _ = writeln!(out, "{k} = {raw}"); } for layer in v.masks.layers() { write_mask(&mut out, &v.uuid, layer); } } out } /// Parse the on-disk form. /// /// Tolerant by design. A sidecar is the authoritative store, so a single /// unreadable line must cost that line and not the file: unrecognised /// keys are preserved rather than rejected, and a malformed value is /// skipped with a warning. The one hard failure is a format version this /// build does not understand, where continuing would mean guessing. pub fn parse(text: &str) -> Result { let mut lines = text.lines(); let header = lines.next().unwrap_or_default().trim(); let format = header .strip_prefix("drsc ") .and_then(|v| v.trim().parse::().ok()) .ok_or(ParseError::NotASidecar)?; if format > FORMAT_VERSION { return Err(ParseError::UnsupportedVersion(format)); } let mut sidecar = Sidecar::new(); let mut current: Option = None; // Mask blocks are collected rather than attached as they are read. // They name their version explicitly, so they need not follow it in // the file — and a `[mask]` block for a version that never appears is // then simply dropped instead of corrupting whichever version // happened to be open. let mut mask: Option = None; // Whether the `[part]` block being read names a mask that is not the // one above it. Its keys are dropped rather than falling through to // the version, where they would be read as somebody's global edit. let mut orphan_part = false; let mut masks: Vec<(String, MaskLayer)> = Vec::new(); for raw in lines { let line = raw.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some(uuid) = line .strip_prefix("[version ") .and_then(|s| s.strip_suffix(']')) { masks.extend(mask.take().and_then(PartialMask::finish)); orphan_part = false; if let Some(v) = current.take() { sidecar.put(v); } current = Some(Version { uuid: uuid.trim().to_string(), ..Version::default() }); continue; } if let Some(head) = line .strip_prefix("[mask ") .and_then(|s| s.strip_suffix(']')) { masks.extend(mask.take().and_then(PartialMask::finish)); orphan_part = false; match head.split_once(char::is_whitespace) { Some((version, id)) => { mask = Some(PartialMask::new(version.trim(), id.trim())); } // A header missing one of its two names cannot be // attached to anything. Dropped with a warning rather // than guessed at, since guessing would put someone // else's adjustment on this photograph. None => log::warn!("sidecar: malformed mask header '{line}'; ignoring"), } continue; } // A part of the mask block above this one. **Three names**, and // the two it repeats are checked rather than assumed: a block // reordered by a hand edit or by a merge would otherwise attach // somebody's correction to whichever mask happened to precede it, // which is a wrong mask rather than a missing one. if let Some(head) = line .strip_prefix("[part ") .and_then(|s| s.strip_suffix(']')) { let mut names = head.split_whitespace(); orphan_part = true; if let (Some(version), Some(layer), Some(part)) = (names.next(), names.next(), names.next()) { match mask.as_mut() { Some(m) if m.version == version && m.id == layer => { m.begin_part(part); orphan_part = false; } _ => log::warn!( "sidecar: part '{part}' names mask {layer} of version {version}, \ which is not the block it follows; ignoring it" ), } } else { log::warn!("sidecar: malformed part header '{line}'; ignoring"); } continue; } let Some((key, value)) = line.split_once('=') else { // Not a key-value line and not a block header. Keep it so a // newer format's construct survives a round trip here. match &mut current { Some(v) => { v.unknown.insert(line.to_string(), String::new()); } None => sidecar.unknown_blocks.push(line.to_string()), } continue; }; let (key, value) = (key.trim(), value.trim()); if orphan_part { continue; } if let Some(m) = mask.as_mut() { m.set(key, value); continue; } let Some(version) = current.as_mut() else { sidecar.unknown_blocks.push(line.to_string()); continue; }; match key { "name" => version.name = value.to_string(), "default" => version.is_default = value != "0", // TRACES: FR-DEV-5 "snapshot-of" => version.snapshot_of = Some(value.to_string()), "revision" => version.revision = value.parse().unwrap_or(0), "device" => version.device = value.to_string(), "modified" => version.modified = value.parse().unwrap_or(0), // Clamped rather than trusted: this file may have been written // by a build with a wider scale, or hand-edited. An // out-of-range rating would sort above five stars forever and // no filter would reach it. "rating" => version.rating = value.parse::().unwrap_or(0).min(MAX_RATING), // TRACES: FR-DEV-3f // Not validated against the installed stocks here: this crate // does not link them, and a file naming a stock this device // lacks must round-trip unharmed rather than be silently // dropped. Whoever bakes it reports the miss. "film" => { version.film.get_or_insert_with(FilmRef::default).stock = value.to_string(); } "film_print" => { // `get_or_insert` and not a plain field write: key order in // a hand-edited file is not guaranteed, and a paper line // above its film line must not be thrown away. version.film.get_or_insert_with(FilmRef::default).print = Some(value.to_string()); } "flag" => version.flag = value.parse::().unwrap_or(0).min(MAX_FLAG), // TRACES: FR-DEV-8 // Ahead of the `op.param` arm below, which would otherwise try // to read eight numbers as one float and drop the repair with a // warning about a corrupt value. The prefix is safe because a // spot is deliberately *not* an operation (see `crate::spot`), // so no `ops/` declaration can ever claim the name. k if k.starts_with("spot.") => { let id = &k["spot.".len()..]; match parse_spot(id, value) { Some(spot) => { version.spots.place(spot); } None => log::warn!("sidecar: unreadable spot {id}; ignoring it"), } } _ => match key.split_once('.') { // An `op.param` line whose value does not parse is a // corrupt number, not an unknown key; dropping it lets // the rest of the edit load, which beats failing the file. Some((op, param)) => match value.parse::() { Ok(v) if v.is_finite() => { version .params .insert((op.to_string(), param.to_string()), v); } _ => log::warn!("sidecar: unreadable value for {key}; ignoring"), }, None => { version.unknown.insert(key.to_string(), value.to_string()); } }, } } masks.extend(mask.take().and_then(PartialMask::finish)); if let Some(v) = current.take() { sidecar.put(v); } for (uuid, layer) in masks { match sidecar.versions.get_mut(&uuid) { Some(v) => { v.masks.push(layer); } None => log::warn!( "sidecar: mask {} names version {uuid}, which is not in this file; ignoring", layer.id ), } } Ok(sidecar) } } /// TRACES: FR-DEV-8 /// Write one repair as one line. /// /// Fields in order: centre, radius, feather, source offset, opacity, mode — /// and the word `disabled` when it is switched off, which is rare enough that /// it is a trailing marker rather than a ninth number every line carries. /// /// Values are written at the precision they are held at (see `crate::spot`'s /// grid), so a round trip is exact and two devices that placed the same repair /// produce the same line rather than a diff of noise in the sixth decimal. fn write_spot(out: &mut String, spot: &Spot) { let _ = write!( out, "spot.{} = {} {} {} {} {} {} {} {}", spot.id, format_value(spot.centre.0), format_value(spot.centre.1), format_value(spot.radius), format_value(spot.feather), format_value(spot.offset.0), format_value(spot.offset.1), format_value(spot.opacity), spot.mode.name(), ); if !spot.enabled { let _ = write!(out, " disabled"); } let _ = writeln!(out); } /// TRACES: FR-DEV-8 /// Read one `spot. = …` line, or nothing if it cannot be trusted. /// /// A malformed line costs that repair and not the file, which is the rule /// [`parse_stroke`] follows and for the sharper version of its reason: a spot /// read half-way is a patch of one part of the photograph copied over another /// part at random. A missing repair is noticed and re-made in a second; a /// repair in the wrong place looks like the file is damaged. fn parse_spot(id: &str, value: &str) -> Option { if id.is_empty() { return None; } let mut tokens = value.split_whitespace(); let mut number = || { tokens .next() .and_then(|t| t.parse::().ok()) .filter(|v| v.is_finite()) }; let centre = (number()?, number()?); let radius = number()?; let feather = number()?; let offset = (number()?, number()?); let opacity = number()?; let mode = SpotMode::from_name(tokens.next()?)?; // Anything after the mode that is not the one marker this format defines // is a newer build's business. Ignored rather than refused: the repair is // complete without it, and refusing would drop work over a field this // build simply does not know about yet. let enabled = !tokens.any(|t| t == "disabled"); let mut spot = Spot::new(centre, offset, radius); spot.id = id.to_string(); spot.set_feather(feather); spot.set_opacity(opacity); spot.mode = mode; spot.enabled = enabled; Some(spot) } /// Write one mask layer as its own block. /// /// The version uuid is repeated in the header rather than relying on the /// block's position in the file. A sidecar is edited by hand, merged by two /// devices, and round-tripped by builds that do not know what a mask is — /// under all three, "belongs to whichever version appeared above me" is a /// relationship that quietly breaks. Naming it costs one field. fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) { let _ = write!(out, "\n[mask {version} {}]\n", layer.id); if !layer.name.is_empty() { let _ = writeln!(out, "name = {}", layer.name); } // **The base part is written into the layer's own block**, without a name // and without a join, and that is what makes this format change no file // that does not use it: a layer of one part is the same bytes it always // was, and a block with no `[part]` after it reads back as one part // (see [`PartialMask::finish`]). write_source(out, layer.base()); if layer.invert { let _ = writeln!(out, "invert = 1"); } if layer.opacity != 1.0 { let _ = writeln!(out, "opacity = {}", format_value(layer.opacity)); } if !layer.enabled { let _ = writeln!(out, "enabled = 0"); } // TRACES: FR-DEV-19a // The base part's own switch, under a different word from the layer's: // `enabled` in this block has always meant the layer, and a part that is // out of the build is `hidden` wherever it is written, base or not. if layer.base().hidden { let _ = writeln!(out, "hidden = 1"); } write_shaping(out, layer.base()); for (op, param, value) in layer.params() { let _ = writeln!(out, "{op}.{param} = {}", format_value(value)); } write_coverage(out, layer.base()); for part in &layer.parts()[1..] { write_part(out, version, &layer.id, part); } } /// Write one part of a layer's mask as its own block. /// /// Its own block rather than a nested key, because a merge compares lines and /// a part is the granularity a photographer edits: adding a correction to a /// mask should read, in a diff, as a correction added — not as the whole mask /// having been rewritten. The version and the layer are both named in the /// header for the reason [`write_mask`] names the version: "belongs to /// whatever appeared above me" is a relationship that does not survive a hand /// edit or a merge. fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) { let _ = write!(out, "\n[part {version} {layer} {}]\n", part.id); let _ = writeln!(out, "join = {}", part.join.name()); write_source(out, part); if part.invert { let _ = writeln!(out, "invert = 1"); } if part.hidden { let _ = writeln!(out, "hidden = 1"); } write_shaping(out, part); write_coverage(out, part); } /// The `source = …` line and whatever else that kind of source needs. fn write_source(out: &mut String, part: &MaskPart) { let _ = writeln!(out, "source = {}", part.source.kind()); match &part.source { MaskSource::Regions { signature, level, ids, } => { let _ = writeln!(out, "signature = {signature}"); let _ = writeln!(out, "level = {level}"); // One space-separated line rather than a key per id: a selection // is a few hundred numbers, and three hundred lines of // `region.7 = 1` would bury the rest of the file. let list: Vec = ids.iter().map(|i| i.to_string()).collect(); let _ = writeln!(out, "regions = {}", list.join(" ")); } MaskSource::Subject { signature, index, class, score, } => { let _ = writeln!(out, "signature = {signature}"); let _ = writeln!(out, "index = {index}"); let _ = writeln!(out, "class = {class}"); let _ = writeln!(out, "score = {}", format_value(*score)); } MaskSource::Category { signature, name } => { let _ = writeln!(out, "signature = {signature}"); let _ = writeln!(out, "category = {name}"); } MaskSource::Linear { centre, angle, width, } => { let _ = writeln!( out, "centre = {} {}", format_value(centre.0), format_value(centre.1) ); let _ = writeln!(out, "angle = {}", format_value(*angle)); let _ = writeln!(out, "width = {}", format_value(*width)); } MaskSource::Radial { centre, radii, angle, feather, } => { let _ = writeln!( out, "centre = {} {}", format_value(centre.0), format_value(centre.1) ); let _ = writeln!( out, "radii = {} {}", format_value(radii.0), format_value(radii.1) ); let _ = writeln!(out, "angle = {}", format_value(*angle)); let _ = writeln!(out, "feather = {}", format_value(*feather)); } MaskSource::Brush { strokes } => write_strokes(out, strokes), // TRACES: FR-DEV-10 // `band` rather than a key per edge, and the same key for both range // sources. The two bounds and their softness are one quantity to a // reader — "the band this mask selects" — and splitting them over // three lines makes a hand edit that moves one edge and forgets the // other look like a file that was written that way. MaskSource::Luminance { lo, hi, softness } => { let _ = writeln!( out, "band = {} {} {}", format_value(*lo), format_value(*hi), format_value(*softness) ); } MaskSource::Colour { hue, hue_width, chroma_lo, chroma_hi, softness, } => { // The chroma half goes out as `band` so that the two range // sources share a key for the same idea, and the arc gets its // own: a hue is a position on a circle and its neighbours are not // bounds in the sense the chroma pair are. let _ = writeln!( out, "hue = {} {}", format_value(*hue), format_value(*hue_width) ); let _ = writeln!( out, "band = {} {} {}", format_value(*chroma_lo), format_value(*chroma_hi), format_value(*softness) ); } } } /// The edge treatment, written only where it is not the default — the same /// rule the parameters follow, so a file stays readable and a mask nobody has /// fiddled with contributes four fewer lines. fn write_shaping(out: &mut String, part: &MaskPart) { if part.feather != DEFAULT_FEATHER { let _ = writeln!(out, "edge-feather = {}", format_value(part.feather)); } if part.falloff != Falloff::default() { let _ = writeln!(out, "edge-falloff = {}", part.falloff.name()); } if part.morphology != Morphology::default() { let _ = writeln!(out, "morphology = {}", part.morphology.name()); let _ = writeln!(out, "morph-radius = {}", format_value(part.morph_radius)); } // Absent means zero, which is the model's own weighting — so a file // written before this control existed reads back looking exactly as it did. if part.refine != 0.0 { let _ = writeln!(out, "refine = {}", format_value(part.refine)); } } /// TRACES: FR-DEV-3 | FR-CAT-8 /// The pixels a model found, so that opening the photograph again — or /// exporting it from the grid, where no model is ever run — renders the part /// instead of silently dropping it. See [`crate::coverage`] for the encoding /// and for why it is one line. /// /// **Last in the block, and that is on purpose.** It is thousands of /// characters against a dozen elsewhere, and a sidecar is read by hand when an /// edit has gone wrong (ARCH §6.12); everything a human is looking for should /// be above it rather than after it. /// /// Omitted, not truncated, when it will not encode: a part with no stored /// coverage behaves exactly as every layer did before this existed, which is a /// mask that needs the model run — where a *partial* one would be a mask that /// is confidently wrong. fn write_coverage(out: &mut String, part: &MaskPart) { if let Some(coverage) = part.coverage.as_ref() { let _ = writeln!(out, "coverage = {}", coverage.to_text()); } } /// Write a brush layer's strokes, one line each. /// /// A line per stroke, in the order they were painted, because the order *is* /// the mask: an erase after an add removes it and the same pair reversed does /// not. It is also the granularity anyone reading a diff wants — a stroke is /// what the user made and what an undo takes back. A line per point would bury /// the rest of the file, and one line for the whole layer would make adding a /// stroke look like the entire mask had been rewritten. /// /// Points are `x,y` pairs rather than a flat run of numbers. A truncated or /// hand-edited line would otherwise shift every coordinate by one and land the /// mask somewhere else entirely, which is the failure that looks like the /// software forgot the edit rather than like a damaged file. fn write_strokes(out: &mut String, strokes: &[Stroke]) { for stroke in strokes { let _ = write!( out, "stroke = {} {} {} {}", if stroke.erase { "erase" } else { "add" }, format_value(stroke.radius), format_value(stroke.hardness), format_value(stroke.flow), ); for (x, y) in &stroke.points { let _ = write!(out, " {},{}", format_value(*x), format_value(*y)); } let _ = writeln!(out); } } /// Read one `stroke = …` line, or nothing if it cannot be trusted. /// /// A malformed stroke costs that stroke and not the layer. Refusing the whole /// block would throw away every other stroke on it over one bad line, and /// guessing at the missing half would put paint somewhere the user never /// touched — which of the three is worst depends on the line, but a wrong mask /// is the only one that looks like it worked. fn parse_stroke(value: &str) -> Option { let mut tokens = value.split_whitespace(); let erase = match tokens.next()? { "add" => false, "erase" => true, other => { log::warn!("sidecar: stroke is neither add nor erase ('{other}'); ignoring it"); return None; } }; let radius: f32 = tokens.next()?.parse().ok()?; let hardness: f32 = tokens.next()?.parse().ok()?; let flow: f32 = tokens.next()?.parse().ok()?; if !(radius.is_finite() && hardness.is_finite() && flow.is_finite()) { return None; } let mut stroke = Stroke::new(erase, radius, hardness, flow); for token in tokens { let (x, y) = token.split_once(',')?; let (x, y) = (x.parse::().ok()?, y.parse::().ok()?); if !(x.is_finite() && y.is_finite()) { return None; } // Straight onto the list rather than through `push_point`, which drops // a point too close to the last: that rule belongs to a finger being // dragged, and applying it here would quietly rewrite a stroke every // time the file was read — so a sidecar would not survive its own round // trip, and two devices would rewrite each other's masks forever. stroke.points.push((x, y)); } (!stroke.is_empty()).then_some(stroke) } /// One part of a mask being read, before it is complete enough to be a part. /// /// Separate from [`MaskPart`] because the source cannot be built until every /// one of its fields has been seen, and the fields arrive one line at a time /// in whatever order the writer chose. struct PartialPart { id: String, join: Join, kind: String, signature: u64, level: u32, ids: Vec, index: u32, class: String, category: String, score: f32, centre: (f32, f32), radii: (f32, f32), angle: f32, width: f32, feather: f32, /// TRACES: FR-DEV-10 /// A range source's band: its two bounds and the fade at each edge. /// /// One triple for both range kinds — tone positions for a luminance /// part, chroma for a colour one — because they are the same line in the /// file and reading them into two sets of fields would be two ways to /// spell one thing. band: (f32, f32, f32), /// A colour range's arc: centre and half-width, in turns. hue: (f32, f32), invert: bool, hidden: bool, /// The *part's* edge transition, distinct from the radial source's own /// `feather` above — different quantity, different units, different key. edge_feather: f32, falloff: Falloff, morphology: Morphology, morph_radius: f32, refine: f32, coverage: Option, strokes: Vec, } impl PartialPart { fn new(id: &str) -> Self { Self { id: id.to_string(), join: Join::default(), kind: String::new(), signature: 0, level: 0, ids: Vec::new(), index: 0, category: String::new(), class: String::new(), score: 0.0, centre: (0.5, 0.5), radii: (0.25, 0.25), angle: 0.0, width: 0.0, feather: 0.0, // The defaults a new layer gets, so a block that names a range // source and nothing else reads back as the mask the panel would // have made rather than as an empty band selecting nothing. band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS), hue: (0.06, 0.05), invert: false, hidden: false, edge_feather: DEFAULT_FEATHER, falloff: Falloff::default(), morphology: Morphology::default(), morph_radius: 0.0, refine: 0.0, coverage: None, strokes: Vec::new(), } } /// Take one key, returning whether it was one of this part's. fn set(&mut self, key: &str, value: &str) -> bool { match key { "source" => self.kind = value.to_string(), "signature" => self.signature = value.parse().unwrap_or(0), "level" => self.level = value.parse().unwrap_or(0), "regions" => { self.ids = value .split_whitespace() .filter_map(|t| t.parse().ok()) .collect(); // Sorted and deduplicated on the way in rather than trusted // from the file: the mask's identity is the *set*, and a // hand-edited or merged line arriving out of order would // otherwise be a different cache key for the same selection. self.ids.sort_unstable(); self.ids.dedup(); } // A category's own key rather than reusing `class`: both name a // thing the mask covers, but one is a COCO instance's label and // the other an entry in the scene descriptor, and a file that // conflated them would round-trip a subject into a category. "category" => self.category = value.to_string(), "index" => self.index = value.parse().unwrap_or(0), "class" => self.class = value.to_string(), "score" => self.score = value.parse().unwrap_or(0.0), "centre" => self.centre = pair(value).unwrap_or(self.centre), "radii" => self.radii = pair(value).unwrap_or(self.radii), "angle" => self.angle = value.parse().unwrap_or(0.0), "width" => self.width = value.parse().unwrap_or(0.0), "feather" => self.feather = value.parse().unwrap_or(0.0), // TRACES: FR-DEV-10 // Kept as written and ordered later, in `MaskSource::*_range`. // Clamping here as well would be a second place the band's // invariants live, and the two would drift. "band" => self.band = triple(value).unwrap_or(self.band), "hue" => self.hue = pair(value).unwrap_or(self.hue), // A cache, so an unreadable one is dropped rather than refused: // the layer still says what it selects, and the worst a `None` // here costs is a model run. Refusing the layer over it would // throw away an edit to protect a copy of something reproducible. "coverage" => { self.coverage = Coverage::parse(value); if self.coverage.is_none() { log::warn!( "sidecar: mask {} has unreadable coverage; it will need the model run", self.id ); } } // Appended rather than assigned: a brush layer is a list of these, // and the file's line order is the order they were painted in. "stroke" => self.strokes.extend(parse_stroke(value)), "invert" => self.invert = value != "0", // Absent means shown, so a file from before the switch existed // reads back with every part in the build, as it was written. "hidden" => self.hidden = value != "0", // Clamped, not trusted: a feather wider than the frame is not a // mask, and a negative one is a distance field read backwards. "edge-feather" => { self.edge_feather = value .parse::() .unwrap_or(DEFAULT_FEATHER) .clamp(0.0, 1.0) } "morph-radius" => { self.morph_radius = value.parse::().unwrap_or(0.0).clamp(0.0, 1.0) } // Clamped for the same reason the feather is: a strictness off the // end of the scale is a category that has deleted itself, and a // file is not trusted to ask for that. "refine" => { self.refine = value .parse::() .unwrap_or(0.0) .clamp(0.0, crate::mask::MAX_REFINE) } // An unrecognised name falls back to the default rather than // dropping the layer. A newer build's falloff curve is a cosmetic // difference in the edge; losing the selection under it would not // be cosmetic. "edge-falloff" => { self.falloff = Falloff::from_name(value).unwrap_or_else(|| { log::warn!("sidecar: unknown falloff '{value}'; using the default"); Falloff::default() }) } "morphology" => { self.morphology = Morphology::from_name(value).unwrap_or_else(|| { log::warn!("sidecar: unknown morphology '{value}'; using none"); Morphology::default() }) } // Not one of ours. The caller decides what that means: an // adjustment's parameter inside the mask block, or a key nothing // in this build understands. _ => return false, } true } /// Build the part, or `None` if the source kind is one this build has /// never heard of — a newer format's mask type, which is skipped rather /// than guessed at. fn finish(self) -> Option { let source = match self.kind.as_str() { "regions" => MaskSource::Regions { signature: self.signature, level: self.level, ids: self.ids, }, "subject" => MaskSource::Subject { signature: self.signature, index: self.index, class: self.class, score: self.score, }, "category" => MaskSource::Category { signature: self.signature, name: self.category, }, "linear" => MaskSource::Linear { centre: self.centre, angle: self.angle, width: self.width, }, "radial" => MaskSource::Radial { centre: self.centre, radii: self.radii, angle: self.angle, feather: self.feather, }, "brush" => MaskSource::Brush { strokes: self.strokes, }, // TRACES: FR-DEV-10 // Through the constructors, not built by hand: they are where the // band's invariants are kept, and a file is exactly the input // that has not been through them. "luminance" => MaskSource::luminance_range(self.band.0, self.band.1, self.band.2), "colour" => MaskSource::colour_range( self.hue.0, self.hue.1, self.band.0, self.band.1, self.band.2, ), other => { log::warn!( "sidecar: unknown mask source '{other}'; skipping part {}", self.id ); return None; } }; let mut part = MaskPart::new(self.id, self.join, source); part.invert = self.invert; part.hidden = self.hidden; part.feather = self.edge_feather; part.falloff = self.falloff; part.morphology = self.morphology; part.morph_radius = self.morph_radius; part.refine = self.refine; // Only where there is a model behind the part to have produced it. A // gradient or a brush that arrived carrying one is a file that has // been hand-edited or written by a build that means something else by // the key, and honouring it would upload a raster nothing samples. part.coverage = match part.source { MaskSource::Subject { .. } | MaskSource::Category { .. } => { self.coverage.map(std::sync::Arc::new) } _ => None, }; Some(part) } } /// A mask block being read, before it is complete enough to be a layer. /// /// Holds what belongs to the *layer* — its name, its inversion, its opacity, /// its adjustments — and one [`PartialPart`] per selection the mask is built /// from. `parts[0]` is filled by the mask block's own keys, which is what /// makes a file written before parts existed read back as a layer of one. struct PartialMask { version: String, id: String, name: String, invert: bool, opacity: f32, enabled: bool, params: Vec<(String, String, f32)>, parts: Vec, /// Whether a `[part]` block is open, so `invert` can be told apart: in the /// mask block it flips the finished mask, and in a part block it flips /// that part before it is joined. Same word, two controls, and the block /// is the only thing that distinguishes them. in_part: bool, } impl PartialMask { fn new(version: &str, id: &str) -> Self { Self { version: version.to_string(), id: id.to_string(), name: String::new(), invert: false, opacity: 1.0, enabled: true, params: Vec::new(), parts: vec![PartialPart::new(crate::mask::FIRST_PART_ID)], in_part: false, } } /// Open a `[part]` block. Every key from here to the next block header /// belongs to it. fn begin_part(&mut self, id: &str) { self.parts.push(PartialPart::new(id)); self.in_part = true; } fn set(&mut self, key: &str, value: &str) { // The layer's own keys, and only while no part block is open. A part // has an `invert` of its own and no opacity at all, so which of the // two an `invert` line means is decided by the block it is in. if !self.in_part { match key { "name" => { self.name = value.to_string(); return; } "invert" => { self.invert = value != "0"; return; } "opacity" => { self.opacity = value.parse::().unwrap_or(1.0).clamp(0.0, 1.0); return; } "enabled" => { self.enabled = value != "0"; return; } _ => {} } } else if key == "join" { // Unknown falls back to a union rather than dropping the part: a // join this build does not have is a part that joins some other // way, and adding it is the reading that keeps the selection // visible and therefore fixable. Silently subtracting under a name // nobody could see would not be. if let Some(part) = self.parts.last_mut() { part.join = Join::from_name(value).unwrap_or_else(|| { log::warn!("sidecar: unknown join '{value}'; adding the part instead"); Join::Union }); } return; } if let Some(part) = self.parts.last_mut() { if part.set(key, value) { return; } } // Whatever is left is an adjustment, which belongs to the layer // however deep in the block it was written. match (key.split_once('.'), value.parse::()) { (Some((op, param)), Ok(v)) if v.is_finite() => { self.params.push((op.to_string(), param.to_string(), v)); } _ => log::warn!("sidecar: unreadable mask key {key}; ignoring"), } } /// Build the layer, or `None` when its base part cannot be built — a /// newer format's mask type, which is skipped rather than guessed at. /// /// A *later* part that cannot be built costs that part and not the layer, /// on the rule [`parse_stroke`] already follows: the rest of the mask is /// work the photographer did, and throwing it away to protect the /// consistency of one correction loses more than it saves. fn finish(self) -> Option<(String, MaskLayer)> { let id = self.id; let mut parts = Vec::new(); for (i, part) in self.parts.into_iter().enumerate() { match (part.finish(), i) { (Some(p), _) => parts.push(p), (None, 0) => return None, (None, _) => {} } } let mut layer = MaskLayer::from_parts(id, parts)?; layer.name = self.name; layer.invert = self.invert; layer.opacity = self.opacity; layer.enabled = self.enabled; for (op, param, value) in &self.params { // `ParamId` holds a `&'static str` and this one came off disk, so // it is matched against the descriptors and the *static* id is // what reaches the operation — exactly what `resolve` does for the // global chain. let Some(id) = layer .ops .iter() .find(|o| o.descriptor().id.0 == op) // The descriptor is bound inside the closure rather than // chained through: it is an owned `Arc` now, so a // `ParamDescriptor` borrowed out of it would not outlive the // expression. The `ParamId` is `Copy`, so it does. .and_then(|o| { o.descriptor() .params .iter() .find(|p| p.id.0 == param) .map(|p| p.id) }) else { log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring"); continue; }; layer.set_param(op, id, *value); } Some((self.version, layer)) } } /// Two whitespace-separated floats. fn pair(value: &str) -> Option<(f32, f32)> { let mut it = value.split_whitespace(); let a: f32 = it.next()?.parse().ok()?; let b: f32 = it.next()?.parse().ok()?; // A NaN parses — `"nan"` is a valid float — and then survives every clamp // downstream, because every comparison against it is false. It reaches a // uniform, and a mask whose geometry or band is NaN selects nothing while // looking, in the file and in the panel, exactly like one that should. (a.is_finite() && b.is_finite()).then_some((a, b)) } /// TRACES: FR-DEV-10 /// Three whitespace-separated floats — a range mask's band. fn triple(value: &str) -> Option<(f32, f32, f32)> { let mut it = value.split_whitespace(); let a: f32 = it.next()?.parse().ok()?; let b: f32 = it.next()?.parse().ok()?; let c: f32 = it.next()?.parse().ok()?; (a.is_finite() && b.is_finite() && c.is_finite()).then_some((a, b, c)) } /// Format a value without a trailing `.0` on whole numbers, and without /// exponent notation — both so the file stays diffable and hand-readable. fn format_value(v: f32) -> String { let mut s = format!("{v:.6}"); if s.contains('.') { s = s.trim_end_matches('0').trim_end_matches('.').to_string(); } if s == "-0" { s = "0".to_string(); } s } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseError { /// The header line was missing or not a `drsc` header. NotASidecar, /// Written by a newer build, in a format this one cannot read. UnsupportedVersion(u32), } impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NotASidecar => f.write_str("not a DarkRoom sidecar"), Self::UnsupportedVersion(v) => { write!(f, "sidecar format version {v} is newer than this build") } } } } impl std::error::Error for ParseError {} #[cfg(test)] mod tests { use super::*; use crate::framing; use crate::ops::{curve, exposure, saturation, white_balance}; fn edited() -> EditGraph { let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 0.75); g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0); g } fn version_of(graph: &EditGraph) -> Version { Version::from_graph("uuid-1", "Default", graph) } /// A graph developing on a stock, with tables well-formed enough for the /// film node to keep them. The emulsion is invented; what is under test /// is whether the *choice* reaches the file. fn on_film(stock: &str) -> EditGraph { let mut g = edited(); g.set_film(Some(crate::graph::Film { stock: stock.to_string(), print: None, tables: crate::ops::FilmTables { exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES], curve_log_min: -3.0, curve_log_max: 1.0, lut: vec![[0.5, 0.5, 0.5]; 8], density_max: 2.0, lut_size: 2, grain_particles: [0.0; 3], grain_density_max: [2.0; 3], grain_uniformity: 1.0, }, })); g } #[test] fn saving_an_edit_keeps_the_film_it_was_developed_on() { // `update` is the write path — the one an automatic save goes through // — and it used to copy the parameters and the masks and say nothing // about the film. A photograph developed on a stock was written back // without it, so the next time it opened, the emulsion was gone and // nothing had reported a failure. // // It reads as an oversight because it was one, and that is the point: // three routines captured "the edit" and each captured a different // subset. All three now destructure one `EditState`, so the next part // of an edit cannot be forgotten by anybody writing a line too few. let mut v = version_of(&on_film("kodak_portra_400")); v.film = None; v.update(&on_film("kodak_portra_400"), "device-a", 1000); assert_eq!( v.film, Some(FilmRef { stock: "kodak_portra_400".into(), print: None, }), "the stock did not survive the save" ); } #[test] fn a_film_written_by_update_comes_back_off_the_disk() { // End to end, because the field being set is only half of it: the // stock has to reach the text and parse back out of it. let mut v = version_of(&EditGraph::default_chain()); v.update(&on_film("ilford_hp5"), "device-a", 1000); let mut sidecar = Sidecar::new(); sidecar.put(v); let parsed = Sidecar::parse(&sidecar.to_text()).expect("re-read"); let mut graph = EditGraph::default_chain(); let rebake = parsed .default_version() .expect("a version") .apply(&mut graph); assert_eq!( rebake.wanted().map(|f| f.stock.as_str()), Some("ilford_hp5"), "reopening the photograph has to ask for its stock back" ); } #[test] fn only_non_default_values_are_written() { // The property the whole format rests on: a neutral operation is // absent, so a file stays small and an operation added later reads // as neutral rather than as missing. let v = version_of(&edited()); assert_eq!(v.params.len(), 2); assert!(v .params .contains_key(&("exposure".into(), "exposure".into()))); assert!(!v.params.keys().any(|(op, _)| op == saturation::ID.0)); } #[test] fn a_neutral_graph_writes_no_parameters() { let v = version_of(&EditGraph::default_chain()); assert!(v.params.is_empty()); } #[test] fn a_graph_round_trips_through_text() { let mut sidecar = Sidecar::new(); sidecar.put(version_of(&edited())); let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let mut restored = EditGraph::default_chain(); parsed .default_version() .expect("a version") .apply(&mut restored) .expect_no_film(); assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75)); assert_eq!( restored.param(white_balance::ID, white_balance::TEMPERATURE), Some(30.0) ); assert!(!restored.is_neutral()); } #[test] fn every_parameter_in_the_chain_round_trips() { // The generic claim, asserted against the whole chain rather than a // sample: if an operation needed special handling to persist, this // is where it would fail. let mut g = EditGraph::default_chain(); for cap in g.capabilities() { for p in &cap.params { if let crate::ParamKind::Scalar { max, precision, .. } = p.kind { let step = 10f32.powi(i32::from(precision)); let target = (max * 0.5 * step).round() / step; g.set_param(cap.id, p.id, target); } } } let mut sidecar = Sidecar::new(); sidecar.put(version_of(&g)); let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let mut restored = EditGraph::default_chain(); parsed .default_version() .expect("a version") .apply(&mut restored) .expect_no_film(); for cap in g.capabilities() { for p in &cap.params { assert_eq!( restored.param(cap.id, p.id), Some(p.value), "{}.{} did not survive the sidecar", cap.id, p.id ); } } } #[test] fn framing_survives_the_round_trip() { // Framing is not an `Operation`, so it is exactly the stage a // serialiser written against the op list alone would silently drop. let mut g = EditGraph::default_chain(); g.set_crop(crate::CropRect { x: 0.1, y: 0.2, width: 0.5, height: 0.6, }); g.set_param(framing::ID, framing::ANGLE, -1.5); g.rotate_quarters(1); let mut sidecar = Sidecar::new(); sidecar.put(version_of(&g)); let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let mut restored = EditGraph::default_chain(); parsed .default_version() .expect("a version") .apply(&mut restored) .expect_no_film(); assert_eq!(restored.crop(), g.crop()); assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5)); assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0)); } #[test] fn a_sidecar_neither_records_nor_erases_the_files_orientation() { // How a file stored its pixels is a fact about the file, so it must // not travel in the sidecar — a shared edit would then carry one // camera's sensor scan onto another's. Two failures are checked // together because they are the same mistake seen from each end. let mut sideways = EditGraph::default_chain(); sideways.set_orientation(dr_types::Orientation::from_exif(6)); // Nothing was edited, so there is nothing to write. If the baseline // leaked into `param`, a rotation would appear here. let v = version_of(&sideways); let text = { let mut s = Sidecar::new(); s.put(v); s.to_text() }; assert!( !text.contains("framing.rotation"), "an untouched sideways file wrote a rotation:\n{text}" ); // And applying an edit — which resets the graph first — must leave the // orientation where it was, or reopening an edited portrait frame // shows it on its side. let mut edited = EditGraph::default_chain(); edited.set_param(framing::ID, framing::ANGLE, -1.5); let mut sidecar = Sidecar::new(); sidecar.put(version_of(&edited)); Sidecar::parse(&sidecar.to_text()) .expect("valid") .default_version() .expect("a version") .apply(&mut sideways) .expect_no_film(); assert_eq!( sideways.framing().baseline(), dr_types::Orientation::from_exif(6) ); assert_eq!(sideways.output_size(6000, 4000), (4000, 6000)); } #[test] fn applying_a_version_replaces_rather_than_overlays() { // Loading an edit onto a graph that already holds one must not leave // the previous image's exposure behind. let mut sidecar = Sidecar::new(); sidecar.put(version_of(&EditGraph::default_chain())); let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let mut g = edited(); parsed .default_version() .expect("a version") .apply(&mut g) .expect_no_film(); assert!(g.is_neutral(), "a neutral version must clear the graph"); } #[test] fn writing_the_same_state_twice_is_byte_identical() { // What lets a caller skip an upload by comparing content. let mut a = Sidecar::new(); a.put(version_of(&edited())); let once = a.to_text(); let twice = Sidecar::parse(&once).expect("valid").to_text(); 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) .expect_no_film(); // 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) .expect_no_film(); 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()); } /// TRACES: FR-PLG-8 /// FR-PLG-8's preservation clause, and only that clause. /// /// The requirement opens by saying the sidecar "already preserves lines it /// does not understand verbatim and writes them back untouched", and that /// the property "is now load-bearing and shall be treated as such". This is /// the test that makes it load-bearing rather than incidental. /// /// **It is weaker than the requirement's stated acceptance criterion**, /// which is that the re-saved file be *byte-identical* to the original. /// This asserts `contains`. The two halves of that criterion exist /// separately — `writing_the_same_state_twice_is_byte_identical` proves /// byte identity for content this build understands, and this proves /// survival for content it does not — and nothing yet joins them into the /// one claim FR-PLG-8 makes. Everything else the requirement asks for /// (aggregated alerts, the export gate, rendering without rather than /// guessing) is unbuilt, which is why the tag is on these two tests and /// not on the module. #[test] fn an_unknown_operation_survives_a_round_trip() { // The data-loss case that matters: a device running an older build // opens a file written by a newer one, saves, and must not delete // the operation it never understood. let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 3\nmodified = 5\n\ exposure.exposure = 0.5\ntime_machine.year = 1994\n"; let parsed = Sidecar::parse(text).expect("valid"); let written = parsed.to_text(); assert!( written.contains("time_machine.year = 1994"), "an unknown operation must not be dropped:\n{written}" ); } /// TRACES: FR-PLG-8 /// The other half of preservation: kept in the file, kept out of the edit. /// /// "Render without, never render a guess" is a separate clause, and this is /// not it — that one is about a *recognised* operation whose plugin is /// missing. This is the narrower claim that an unparsed line cannot reach /// the graph by accident, which is what makes preserving it safe. #[test] fn an_unknown_operation_does_not_reach_the_graph() { let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\ time_machine.year = 1994\n"; let parsed = Sidecar::parse(text).expect("valid"); let mut g = EditGraph::default_chain(); parsed .default_version() .expect("a version") .apply(&mut g) .expect_no_film(); assert!(g.is_neutral()); } #[test] fn a_corrupt_value_costs_its_line_and_not_the_file() { let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\ exposure.exposure = NaN\nwhite_balance.temperature = 20\n"; let parsed = Sidecar::parse(text).expect("valid"); let mut g = EditGraph::default_chain(); parsed .default_version() .expect("a version") .apply(&mut g) .expect_no_film(); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); assert_eq!( g.param(white_balance::ID, white_balance::TEMPERATURE), Some(20.0) ); } #[test] fn an_out_of_range_value_is_clamped_rather_than_trusted() { // A sidecar written by a build with a wider range must not put an // out-of-range value into a uniform. let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\ exposure.exposure = 99\n"; let parsed = Sidecar::parse(text).expect("valid"); let mut g = EditGraph::default_chain(); parsed .default_version() .expect("a version") .apply(&mut g) .expect_no_film(); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0)); } #[test] fn a_newer_format_version_is_refused_rather_than_guessed() { let err = Sidecar::parse("drsc 99\n").unwrap_err(); assert_eq!(err, ParseError::UnsupportedVersion(99)); } #[test] fn a_non_sidecar_is_rejected() { assert_eq!( Sidecar::parse("").unwrap_err(), ParseError::NotASidecar ); } #[test] fn several_versions_are_kept_apart() { // FR-CAT-12: one image, several virtual copies, independent edits. let mut sidecar = Sidecar::new(); let mut a = Version::from_graph("u1", "Colour", &edited()); a.is_default = true; let mut mono = EditGraph::default_chain(); mono.set_param(saturation::ID, saturation::SATURATION, -100.0); sidecar.put(a); sidecar.put(Version::from_graph("u2", "Mono", &mono)); let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); assert_eq!(parsed.versions.len(), 2); assert_eq!(parsed.default_version().expect("default").name, "Colour"); let mut g = EditGraph::default_chain(); parsed.versions["u2"].apply(&mut g).expect_no_film(); assert_eq!( g.param(saturation::ID, saturation::SATURATION), Some(-100.0) ); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0)); } /// TRACES: FR-DEV-5 /// A snapshot is a version with a pointer, and the pointer survives the /// file: it comes back as a snapshot of the edit it was taken from, with /// the edit it stored, and the photograph still opens at its default. #[test] fn a_snapshot_round_trips_as_a_snapshot_of_its_edit() { let mut sidecar = Sidecar::new(); let mut current = Version::from_graph("u1", "Default", &edited()); current.is_default = true; sidecar.put(current); let mut mono = EditGraph::default_chain(); mono.set_param(saturation::ID, saturation::SATURATION, -100.0); let mut snapshot = Version::from_graph("snap-1", "Black and white", &mono); snapshot.snapshot_of = Some("u1".into()); snapshot.modified = 7; sidecar.put(snapshot); let text = sidecar.to_text(); assert!(text.contains("snapshot-of = u1"), "{text}"); let parsed = Sidecar::parse(&text).expect("valid"); assert_eq!(parsed.default_version().expect("default").uuid, "u1"); let snapshots = parsed.snapshots_of("u1"); assert_eq!(snapshots.len(), 1); assert_eq!(snapshots[0].name, "Black and white"); assert!(snapshots[0].is_snapshot()); let mut g = EditGraph::default_chain(); snapshots[0].apply(&mut g).expect_no_film(); assert_eq!( g.param(saturation::ID, saturation::SATURATION), Some(-100.0) ); } /// TRACES: FR-DEV-5 /// A file whose only versions are snapshots has no edit to open, and /// must not answer with one of the snapshots as if it were. #[test] fn a_snapshot_is_never_the_default() { let mut sidecar = Sidecar::new(); let mut snapshot = Version::from_graph("snap-1", "Earlier", &edited()); snapshot.snapshot_of = Some("gone".into()); sidecar.put(snapshot); assert!(sidecar.default_version().is_none()); } /// TRACES: FR-DEV-5 | FR-NC-9 /// Writing a session's snapshots back removes what it deleted and keeps /// what it never saw — another device's snapshot is not this device's to /// remove by not knowing about it. #[test] fn replacing_snapshots_removes_only_what_was_deleted() { let mut sidecar = Sidecar::new(); let mut current = Version::from_graph("u1", "Default", &edited()); current.is_default = true; sidecar.put(current); for id in ["mine-1", "mine-2", "theirs-1"] { let mut v = Version::from_graph(id, id, &edited()); v.snapshot_of = Some("u1".into()); sidecar.put(v); } // This session loaded mine-1 and mine-2, deleted mine-2, took mine-3. let mut kept = Version::from_graph("mine-1", "mine-1", &edited()); kept.snapshot_of = Some("u1".into()); let taken = Version::from_graph("mine-3", "mine-3", &edited()); sidecar.replace_snapshots("u1", vec![kept, taken], &["mine-2".to_string()]); let ids: Vec<&str> = sidecar .snapshots_of("u1") .iter() .map(|v| v.uuid.as_str()) .collect(); assert_eq!(ids, ["mine-1", "mine-3", "theirs-1"]); assert_eq!( sidecar.versions["mine-3"].snapshot_of.as_deref(), Some("u1"), "a snapshot taken without a pointer is pointed at the edit" ); } #[test] fn update_bumps_the_revision() { // FR-NC-9 resolves by revision; a write that did not bump it would // lose to a stale remote. let mut v = version_of(&EditGraph::default_chain()); let before = v.revision; v.update(&edited(), "device-a", 1000); assert_eq!(v.revision, before + 1); assert_eq!(v.device, "device-a"); assert_eq!(v.modified, 1000); } #[test] fn disjoint_edits_both_survive_a_merge() { // FR-NC-9's motivating case, verbatim: a crop on one device and an // exposure change on the other. let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); let mut lg = EditGraph::default_chain(); lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0); local.update(&lg, "device-a", 100); let mut remote = base.clone(); let mut rg = EditGraph::default_chain(); rg.set_crop(crate::CropRect { x: 0.0, y: 0.0, width: 0.5, height: 0.5, }); remote.update(&rg, "device-b", 200); let conflicts = local.merge(&remote, Some(&base)); assert!(conflicts.is_empty(), "disjoint edits must not conflict"); let mut g = EditGraph::default_chain(); local.apply(&mut g).expect_no_film(); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0)); assert_eq!(g.crop().width, 0.5); } #[test] fn a_genuine_conflict_resolves_to_the_higher_revision() { let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); let mut lg = EditGraph::default_chain(); lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0); local.update(&lg, "device-a", 100); let mut remote = base.clone(); let mut rg = EditGraph::default_chain(); rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0); remote.update(&rg, "device-b", 200); remote.revision = local.revision + 1; let conflicts = local.merge(&remote, Some(&base)); assert_eq!(conflicts.len(), 1, "the same parameter, two values"); let mut g = EditGraph::default_chain(); local.apply(&mut g).expect_no_film(); assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0)); } #[test] fn a_skewed_clock_cannot_beat_a_higher_revision() { // Revision ahead of timestamp, deliberately: a device with a wrong // clock must not silently overwrite real work. let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); let mut lg = EditGraph::default_chain(); lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0); local.update(&lg, "device-a", 100); local.revision = 50; let mut remote = base.clone(); let mut rg = EditGraph::default_chain(); rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0); remote.update(&rg, "device-b", 999_999); remote.revision = 2; local.merge(&remote, Some(&base)); let mut g = EditGraph::default_chain(); local.apply(&mut g).expect_no_film(); assert_eq!( g.param(exposure::ID, exposure::EXPOSURE), Some(1.0), "the far-future timestamp must not win against a higher revision" ); } #[test] fn a_remote_reset_to_default_removes_the_parameter() { // Deletion is an edit too: clearing exposure on another device must // propagate, not be masked by the key simply being absent. let mut base = version_of(&edited()); base.revision = 1; let mut local = base.clone(); let mut remote = base.clone(); remote.update(&EditGraph::default_chain(), "device-b", 200); local.merge(&remote, Some(&base)); let mut g = EditGraph::default_chain(); local.apply(&mut g).expect_no_film(); assert!(g.is_neutral(), "the remote reset must survive the merge"); } #[test] fn a_merge_is_newer_than_either_input() { let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); local.revision = 4; let mut remote = base.clone(); remote.revision = 9; local.merge(&remote, Some(&base)); assert!(local.revision > 9, "a merged result must not look stale"); } #[test] fn a_film_choice_survives_the_round_trip() { // TRACES: FR-DEV-3f let mut v = version_of(&edited()); v.film = Some(FilmRef { stock: "kodak_portra_400".into(), print: Some("kodak_portra_endura".into()), }); let mut side = Sidecar::default(); side.versions.insert(v.uuid.clone(), v); let text = side.to_text(); let back = Sidecar::parse(&text).expect("parses"); let film = back.versions.values().next().unwrap().film.clone().unwrap(); assert_eq!(film.stock, "kodak_portra_400"); assert_eq!(film.print.as_deref(), Some("kodak_portra_endura")); } #[test] fn a_film_with_no_print_round_trips_as_a_scan() { // Absent paper is a *choice* — the film as it comes, which for a // colour negative is the orange scan. It must not come back as the // stock's default paper, or "show me the negative" would be // unrepresentable. let mut v = version_of(&edited()); v.film = Some(FilmRef { stock: "kodak_portra_400".into(), print: None, }); let mut side = Sidecar::default(); side.versions.insert(v.uuid.clone(), v); let back = Sidecar::parse(&side.to_text()).expect("parses"); let film = back.versions.values().next().unwrap().film.clone().unwrap(); assert_eq!(film.stock, "kodak_portra_400"); assert_eq!(film.print, None); } #[test] fn no_film_writes_no_film_line() { // The same non-default rule the parameters and the rating follow: a // library nobody has put on film does not grow a line per file. let mut side = Sidecar::default(); let v = version_of(&edited()); side.versions.insert(v.uuid.clone(), v); let text = side.to_text(); assert!(!text.contains("film"), "{text}"); } #[test] fn a_paper_line_above_its_film_line_is_not_lost() { // Key order in a hand-edited file is not guaranteed, and the reader // builds the film from two separate lines. let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\ film_print = kodak_portra_endura\nfilm = kodak_portra_400\n"; let side = Sidecar::parse(text).expect("parses"); let film = side.versions.values().next().unwrap().film.clone().unwrap(); assert_eq!(film.stock, "kodak_portra_400"); assert_eq!(film.print.as_deref(), Some("kodak_portra_endura")); } #[test] fn a_stock_this_build_does_not_have_still_round_trips() { // A profile is a file a user can add. A device without it must hand // the name back untouched rather than drop it, or syncing to an older // phone would quietly un-develop the photograph. let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\ film = ilford_hp5_plus\n"; let side = Sidecar::parse(text).expect("parses"); assert!( side.to_text().contains("film = ilford_hp5_plus"), "{}", side.to_text() ); } #[test] fn the_film_resolves_wholesale_rather_than_field_by_field() { // TRACES: FR-DEV-3f | FR-NC-9 // One decision with two names in it. Taking the stock from one device // and the paper from the other would print a negative on a paper // nobody chose for it -- a combination neither photographer asked for, // and one that renders as a colour cast rather than as an obvious // mistake. let mut base = version_of(&edited()); base.revision = 1; let mut local = base.clone(); local.film = Some(FilmRef { stock: "kodak_portra_400".into(), print: Some("kodak_portra_endura".into()), }); local.revision = 2; let mut remote = base.clone(); remote.film = Some(FilmRef { stock: "kodak_kodachrome_64".into(), print: None, }); remote.revision = 9; local.merge(&remote, Some(&base)); let film = local.film.clone().expect("a film survived"); assert_eq!(film.stock, "kodak_kodachrome_64"); assert_eq!( film.print, None, "the loser's paper was grafted onto the winner's stock" ); } #[test] fn clearing_the_film_elsewhere_propagates() { // Unlike a rating, a cleared film is an edit -- "develop this normally // again" -- so None must travel. A rating's zero does not, because // there "unset" and "set to zero" are indistinguishable; here the // revision says which happened. let mut base = version_of(&edited()); base.film = Some(FilmRef { stock: "kodak_portra_400".into(), print: None, }); base.revision = 1; let mut local = base.clone(); local.revision = 2; let mut remote = base.clone(); remote.film = None; remote.revision = 9; local.merge(&remote, Some(&base)); assert_eq!(local.film, None, "a deliberate clear did not propagate"); } #[test] fn a_rating_survives_the_round_trip() { // The durability requirement: the catalog is disposable (ARCH §6.12), // so a cull that lives only there is a cull one `rm` destroys. let mut v = version_of(&EditGraph::default_chain()); v.rating = 4; v.flag = 1; let mut sidecar = Sidecar::new(); sidecar.put(v); let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let back = parsed.default_version().expect("a version"); assert_eq!(back.rating, 4); assert_eq!(back.flag, 1); } #[test] fn an_unrated_image_writes_no_judgement_lines() { // The non-default rule applied to judgement: a library that has never // been culled must not grow two lines per file. let mut sidecar = Sidecar::new(); sidecar.put(version_of(&EditGraph::default_chain())); let text = sidecar.to_text(); assert!(!text.contains("rating"), "{text}"); assert!(!text.contains("flag"), "{text}"); } #[test] fn judgement_travels_with_an_otherwise_neutral_edit() { // Culling produces no pixel change at all, so this is the *normal* // sidecar during a cull — not an edge case. A writer that skipped // files with a neutral graph would drop every rating. let mut v = version_of(&EditGraph::default_chain()); v.rating = 5; let mut sidecar = Sidecar::new(); sidecar.put(v); let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid"); let back = parsed.default_version().expect("a version"); assert_eq!(back.rating, 5); assert!(back.params.is_empty(), "no edit, just a judgement"); } #[test] fn an_out_of_range_rating_in_a_file_is_clamped() { // Written by a build with a wider scale, or hand-edited. A stored 9 // would sort above five stars and no filter would reach it. let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\ rating = 9\nflag = 77\n"; let parsed = Sidecar::parse(text).expect("valid"); let v = parsed.default_version().expect("a version"); assert_eq!(v.rating, MAX_RATING); assert_eq!(v.flag, MAX_FLAG); } #[test] fn a_corrupt_rating_costs_its_line_and_not_the_file() { let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\ rating = later\nexposure.exposure = 0.5\n"; let parsed = Sidecar::parse(text).expect("valid"); let v = parsed.default_version().expect("a version"); assert_eq!(v.rating, 0); assert_eq!( v.params.get(&("exposure".into(), "exposure".into())), Some(&0.5), "the rest of the edit still loads" ); } #[test] fn a_rating_from_a_device_that_never_culled_does_not_erase_one() { // The failure this merge rule exists to prevent: a tablet that synced // before the cull holds 0, and must not wipe the desktop's afternoon // of work merely by having a later revision. let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); local.rating = 5; local.revision = 2; let mut remote = base.clone(); remote.rating = 0; remote.revision = 99; local.merge(&remote, Some(&base)); assert_eq!(local.rating, 5, "an unjudged remote must not erase a cull"); } #[test] fn a_rating_made_elsewhere_arrives_when_we_have_none() { // The other direction: culling on the tablet must reach the desktop. let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); let mut remote = base.clone(); remote.rating = 3; remote.flag = 1; remote.revision = 5; local.merge(&remote, Some(&base)); assert_eq!(local.rating, 3); assert_eq!(local.flag, 1); } #[test] fn two_devices_that_both_rated_resolve_by_revision() { let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); local.rating = 2; local.revision = 3; let mut remote = base.clone(); remote.rating = 5; remote.revision = 9; local.merge(&remote, Some(&base)); assert_eq!(local.rating, 5, "the higher revision wins a real conflict"); } #[test] fn a_lower_revision_does_not_overwrite_our_rating() { let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); local.rating = 5; local.revision = 40; let mut remote = base.clone(); remote.rating = 1; remote.revision = 2; local.merge(&remote, Some(&base)); assert_eq!(local.rating, 5); } #[test] fn a_rating_and_an_edit_merge_independently() { // Culling on a tablet while editing on a desktop is the whole point of // the multi-device workflow (FR-CULL-7); neither may cost the other. let base = version_of(&EditGraph::default_chain()); let mut local = base.clone(); let mut lg = EditGraph::default_chain(); lg.set_param(exposure::ID, exposure::EXPOSURE, 1.5); local.update(&lg, "desktop", 100); let mut remote = base.clone(); remote.rating = 4; remote.revision = local.revision + 1; local.merge(&remote, Some(&base)); assert_eq!(local.rating, 4, "the tablet's cull arrived"); let mut g = EditGraph::default_chain(); local.apply(&mut g).expect_no_film(); assert_eq!( g.param(exposure::ID, exposure::EXPOSURE), Some(1.5), "and the desktop's edit survived it" ); } #[test] fn values_are_written_without_exponent_notation() { // The file is meant to be readable when an edit goes wrong, and a // sidecar is the authoritative store — so that case matters. assert_eq!(format_value(0.0001), "0.0001"); assert_eq!(format_value(1.0), "1"); assert_eq!(format_value(-0.0), "0"); assert_eq!(format_value(0.75), "0.75"); } } /// TRACES: FR-NC-8 | FR-NC-9 /// Two devices, one photograph, two `default = 1` versions. /// /// The state a library reaches when each device mints its own version uuid. /// These cover both halves of the resulting failure: which version a reader /// picks, and whether the two ever converge. #[cfg(test)] mod split_defaults { use super::*; /// A default version as a device that had never heard of the other would /// write it. fn default_version(uuid: &str, revision: u64, modified: i64, rating: u8) -> Version { Version { uuid: uuid.to_string(), name: "Default".to_string(), is_default: true, revision, modified, rating, ..Default::default() } } fn with(versions: Vec) -> Sidecar { let mut s = Sidecar::new(); for v in versions { s.put(v); } s } /// The observed failure, reduced. Both blocks are `default = 1`; the older /// one sorts first by uuid, and the reader used to answer with it — so a /// rating made on the second device was invisible on the first. #[test] fn the_newer_edit_wins_even_when_its_uuid_sorts_later() { let s = with(vec![ default_version("0545c20a", 1, 1_787_337_630, 4), default_version("679fe872", 2, 1_788_092_930, 1), ]); assert_eq!(s.default_version().unwrap().uuid, "679fe872"); assert_eq!(s.default_version().unwrap().rating, 1); } /// The same file with the uuids the other way round. The answer must /// follow the revision, not the ordering — otherwise the test above only /// passes by luck. #[test] fn the_newer_edit_wins_even_when_its_uuid_sorts_earlier() { let s = with(vec![ default_version("0545c20a", 2, 1_788_092_930, 1), default_version("679fe872", 1, 1_787_337_630, 4), ]); assert_eq!(s.default_version().unwrap().uuid, "0545c20a"); assert_eq!(s.default_version().unwrap().rating, 1); } /// A skewed clock must not beat a real edit — the FR-NC-8 rule, applied at /// the reader as well as at the merge. #[test] fn a_later_timestamp_cannot_beat_a_higher_revision() { let s = with(vec![ default_version("aaaa", 5, 1_000, 3), default_version("bbbb", 2, 9_999_999, 1), ]); assert_eq!(s.default_version().unwrap().rating, 3); } /// Fusing is what makes the two converge rather than merely choosing /// between them: a parameter only the *losing* version holds survives, /// because the merge is key-wise. #[test] fn fusing_keeps_the_disjoint_work_of_both_devices() { let mut old = default_version("0545c20a", 1, 100, 4); old.params .insert(("exposure".into(), "exposure".into()), 0.75); let mut new = default_version("679fe872", 2, 200, 0); new.params .insert(("saturation".into(), "saturation".into()), 0.5); let mut s = with(vec![old, new]); let uuid = s.fuse_default_versions(None).unwrap(); assert_eq!(s.versions.len(), 1, "the split must be closed"); let v = &s.versions[&uuid]; assert_eq!( v.params.get(&("exposure".into(), "exposure".into())), Some(&0.75), "the older device's exposure was dropped" ); assert_eq!( v.params.get(&("saturation".into(), "saturation".into())), Some(&0.5), "the newer device's saturation was dropped" ); // A judgement the loser holds and the winner does not is adopted, for // the reason `merge_judgement` gives: a zero is "never judged". assert_eq!(v.rating, 4); } /// A contested value resolves to the higher revision, not to whichever /// uuid sorted first. #[test] fn fusing_resolves_a_contested_value_by_revision() { let mut old = default_version("aaaa", 9, 100, 0); old.params .insert(("exposure".into(), "exposure".into()), 0.75); let mut new = default_version("bbbb", 1, 999_999, 0); new.params .insert(("exposure".into(), "exposure".into()), -2.0); let mut s = with(vec![old, new]); let uuid = s.fuse_default_versions(None).unwrap(); assert_eq!( s.versions[&uuid] .params .get(&("exposure".into(), "exposure".into())), Some(&0.75), "the higher revision must win" ); } /// Every device must reach the same file from the same input, or two of /// them fuse and upload forever without converging. #[test] fn fusing_is_the_same_on_every_device() { let versions = vec![ default_version("cccc", 1, 100, 2), default_version("aaaa", 3, 300, 0), default_version("bbbb", 2, 200, 0), ]; let mut one = with(versions.clone()); // The other device parsed the same file, so it holds the same set in a // different insertion order. let mut two = with(versions.into_iter().rev().collect()); one.fuse_default_versions(None); two.fuse_default_versions(None); assert_eq!(one.to_text(), two.to_text()); assert_eq!(one.versions.len(), 1); } /// Three or more versions must all fold in. The accumulator's revision /// climbs as it merges, so folding a chain in ascending order would drop /// everything after the second — which is why the fold goes into the /// winner instead. #[test] fn a_third_device_is_not_lost_to_the_accumulators_rising_revision() { let mut a = default_version("aaaa", 1, 100, 0); a.params.insert(("a".into(), "a".into()), 1.0); let mut b = default_version("bbbb", 2, 200, 0); b.params.insert(("b".into(), "b".into()), 2.0); let mut c = default_version("cccc", 3, 300, 0); c.params.insert(("c".into(), "c".into()), 3.0); let mut s = with(vec![a, b, c]); let uuid = s.fuse_default_versions(None).unwrap(); let v = &s.versions[&uuid]; for key in ["a", "b", "c"] { assert!( v.params.contains_key(&(key.to_string(), key.to_string())), "{key} was dropped by the fold" ); } } /// The rename half. A lone default under a randomly minted uuid has to /// move onto the derived one, or the next write matches nothing and adds a /// third version instead of amending this one. #[test] fn a_lone_default_is_renamed_onto_the_canonical_uuid() { let mut s = with(vec![default_version("random-v4", 1, 100, 3)]); let uuid = s.fuse_default_versions(Some("derived")).unwrap(); assert_eq!(uuid, "derived"); assert_eq!(s.versions.len(), 1); assert_eq!(s.versions["derived"].rating, 3, "the edit went with it"); } /// A virtual copy (FR-CAT-12) that happens to hold the canonical uuid must /// not be overwritten by the rename. #[test] fn a_named_version_is_not_destroyed_by_the_rename() { let mut copy = default_version("derived", 1, 100, 5); copy.is_default = false; copy.name = "For print".to_string(); let mut s = with(vec![default_version("random-v4", 1, 100, 3), copy]); let uuid = s.fuse_default_versions(Some("derived")).unwrap(); assert_eq!(uuid, "random-v4", "the rename must be declined"); assert_eq!(s.versions.len(), 2); assert_eq!(s.versions["derived"].name, "For print"); } /// Fusing a file that never needed it must not rewrite it — a sidecar that /// changes on every read is a sidecar that uploads on every read. #[test] fn fusing_a_healthy_sidecar_changes_nothing() { let mut s = with(vec![default_version("only", 4, 400, 2)]); let before = s.to_text(); s.fuse_default_versions(None); assert_eq!(s.to_text(), before); } /// Nothing to fuse is not a failure — most photographs have never been /// edited on any device. #[test] fn an_empty_sidecar_has_no_default_to_fuse() { let mut s = Sidecar::new(); assert_eq!(s.fuse_default_versions(Some("derived")), None); assert!(s.versions.is_empty()); } }