//! 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::graph::EditGraph; use crate::mask::{Falloff, MaskLayer, 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, /// 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, 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(), }) } /// 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() } /// The version marked default, or the first if none is. pub fn default_version(&self) -> Option<&Version> { self.versions .values() .find(|v| v.is_default) .or_else(|| self.versions.values().next()) } /// 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"); } 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; 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)); 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)); 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; } 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 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", "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); } let _ = writeln!(out, "source = {}", layer.source.kind()); match &layer.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::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), } 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"); } // The edge treatment, written only when 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. if layer.feather != DEFAULT_FEATHER { let _ = writeln!(out, "edge-feather = {}", format_value(layer.feather)); } if layer.falloff != Falloff::default() { let _ = writeln!(out, "edge-falloff = {}", layer.falloff.name()); } if layer.morphology != Morphology::default() { let _ = writeln!(out, "morphology = {}", layer.morphology.name()); let _ = writeln!(out, "morph-radius = {}", format_value(layer.morph_radius)); } for (op, param, value) in layer.params() { let _ = writeln!(out, "{op}.{param} = {}", format_value(value)); } } /// 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) } /// A mask block being read, before it is complete enough to be a layer. /// /// Separate from [`MaskLayer`] 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 PartialMask { version: String, id: String, name: String, kind: String, signature: u64, level: u32, ids: Vec, index: u32, class: String, score: f32, centre: (f32, f32), radii: (f32, f32), angle: f32, width: f32, feather: f32, invert: bool, opacity: f32, enabled: bool, /// The *layer'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, strokes: Vec, params: Vec<(String, String, f32)>, } impl PartialMask { fn new(version: &str, id: &str) -> Self { Self { version: version.to_string(), id: id.to_string(), name: String::new(), kind: String::new(), signature: 0, level: 0, ids: Vec::new(), index: 0, 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, invert: false, opacity: 1.0, enabled: true, edge_feather: DEFAULT_FEATHER, falloff: Falloff::default(), morphology: Morphology::default(), morph_radius: 0.0, strokes: Vec::new(), params: Vec::new(), } } fn set(&mut self, key: &str, value: &str) { match key { "name" => self.name = value.to_string(), "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(); } "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), // 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", "opacity" => self.opacity = value.parse::().unwrap_or(1.0).clamp(0.0, 1.0), "enabled" => self.enabled = 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) } // 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() }) } _ => 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` 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<(String, MaskLayer)> { 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, }, "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, }, other => { log::warn!( "sidecar: unknown mask source '{other}'; skipping layer {}", self.id ); return None; } }; let mut layer = MaskLayer::new(self.id, source); layer.name = self.name; layer.invert = self.invert; layer.opacity = self.opacity; layer.enabled = self.enabled; layer.feather = self.edge_feather; layer.falloff = self.falloff; layer.morphology = self.morphology; layer.morph_radius = self.morph_radius; 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) .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 = it.next()?.parse().ok()?; let b = it.next()?.parse().ok()?; Some((a, b)) } /// 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()); } #[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}" ); } #[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)); } #[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"); } }