Files
DarkRoom/core/dr-pipeline/src/sidecar.rs
T
dtourolleandClaude Opus 5 3b5952769b Emit floats an f32 can hold, and drop the format! that formats nothing
CI runs cargo fmt --check and clippy -D warnings, and this branch had
never been through either. Both would have failed it.

The bulk was the generated colour tables: eight significant figures where
an f32 carries about 7.2, so the eighth is noise that rounds away at
compile time and clippy's excessive_precision says so 109 times over.
Fixed in the generator rather than only in the file, so it stays fixed --
and the file is trimmed in place rather than re-derived, because
regenerating it needs a colour-science stack that has nothing to do with
the defect.

The format! in the composer is mine too, from extracting the rendering
tail: the braces in it were escaped because the text used to live inside a
larger template, and once extracted the escapes are noise and the call
formats nothing.

Also here, and clearly not mine: an unused import and a shadowed binding
in dr-gpu, and an unused import in a test. They are pre-existing --
clippy has been failing on master before this branch existed, on lints
like is_multiple_of that arrived with a toolchain rather than with
anyone's code. Fixed because CI cannot go green around them, and called
out because a merge commit is a bad place to quietly edit someone else's
crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:28:14 +02:00

1956 lines
76 KiB
Rust

//! 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::{resolve, Preset};
/// 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<String, Version>,
/// 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<String>,
}
/// TRACES: FR-DEV-3f
/// The stock and paper a version names, without the tables they bake to.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FilmRef {
pub stock: String,
/// The paper, if the negative is printed. Absent means the film is viewed
/// as it comes — which for a colour negative is the scan, orange and
/// inverted, and is a legitimate thing to ask for.
pub print: Option<String>,
}
/// 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<FilmRef>,
/// 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<String, String>,
}
impl Version {
/// A new version holding the non-default parameters of `graph`.
pub fn from_graph(uuid: impl Into<String>, name: impl Into<String>, graph: &EditGraph) -> Self {
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: capture(graph),
masks: graph.masks().clone(),
film: graph.film().map(|f| FilmRef {
stock: f.stock.clone(),
print: f.print.clone(),
}),
unknown: BTreeMap::new(),
}
}
/// Apply this version's parameters to a graph.
///
/// The graph is reset first, so 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.
pub fn apply(&self, graph: &mut EditGraph) {
graph.reset();
for ((op, param), value) in &self.params {
// `OpId` and `ParamId` hold `&'static str` because descriptors
// are statics, and a sidecar's strings are not. `resolve` matches
// the file's names against the descriptors and hands back the
// static ids, so no string read from disk is ever leaked to get
// a lifetime it did not earn.
let Some((op, param)) = resolve(graph, op, param) else {
log::warn!("sidecar: unknown parameter {op}.{param}; ignoring");
continue;
};
graph.set_param(op, param, *value);
}
*graph.masks_mut() = self.masks.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) {
self.params = capture(graph);
self.masks = graph.masks().clone();
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<String> = self
.masks
.layers()
.iter()
.chain(remote.masks.layers())
.map(|l| l.id.clone())
.collect::<std::collections::BTreeSet<_>>()
.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
}
/// 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::<std::collections::BTreeSet<_>>()
.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));
// 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
}
}
}
}
/// Every non-default parameter in the graph, keyed by `(op, param)`.
///
/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls
/// from — so an operation is persisted by virtue of being in the chain, with
/// nothing to register and nothing to forget.
///
/// Delegated to [`Preset::capture`] rather than reimplemented: a version's
/// parameters and a copied preset are the same values taken from the same
/// list, and two routines building the same map would be two places for the
/// non-default rule to drift.
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
Preset::capture(graph).into_params()
}
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));
}
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<Self, ParseError> {
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::<u32>().ok())
.ok_or(ParseError::NotASidecar)?;
if format > FORMAT_VERSION {
return Err(ParseError::UnsupportedVersion(format));
}
let mut sidecar = Sidecar::new();
let mut current: Option<Version> = 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<PartialMask> = 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::<u8>().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::<u8>().unwrap_or(0).min(MAX_FLAG),
_ => 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::<f32>() {
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)
}
}
/// 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<String> = 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<Stroke> {
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::<f32>().ok()?, y.parse::<f32>().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<u32>,
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<Stroke>,
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::<f32>().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::<f32>()
.unwrap_or(DEFAULT_FEATHER)
.clamp(0.0, 1.0)
}
"morph-radius" => {
self.morph_radius = value.parse::<f32>().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::<f32>()) {
(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)
}
#[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);
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);
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);
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);
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);
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);
// The S-curve the file describes, on the master curve and nowhere
// else.
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
for channel in [
curve::Channel::Red,
curve::Channel::Green,
curve::Channel::Blue,
] {
for point in 0..curve::POINTS {
for axis in [curve::Axis::X, curve::Axis::Y] {
let id = curve::coordinate(channel, point, axis);
let expected = g
.capabilities()
.iter()
.find(|c| c.id == curve::ID)
.and_then(|c| c.params.iter().find(|p| p.id == id))
.map(|p| p.default);
assert_eq!(
g.param(curve::ID, id),
expected,
"{id} moved, and no line in the file mentions it"
);
}
}
}
// And writing it back produces the same two lines: the curves the file
// never mentioned are still at their defaults, so they are still
// absent (`only_non_default_values_are_written`).
let written = Sidecar::parse(&Sidecar::parse(text).expect("valid").to_text())
.expect("valid")
.to_text();
assert!(written.contains("tone_curve.p1_y = 0.15"), "{written}");
assert!(written.contains("tone_curve.p3_y = 0.85"), "{written}");
assert!(
!written.contains("tone_curve.r_"),
"an untouched channel curve was written out:\n{written}"
);
}
/// TRACES: FR-DEV-3
/// The other direction: the new curves persist like any other parameter.
#[test]
fn a_per_channel_curve_survives_the_round_trip() {
let mut g = EditGraph::default_chain();
// A faded shadow: blue lifted at the black point, red pulled down.
let blue = curve::coordinate(curve::Channel::Blue, 0, curve::Axis::Y);
let red = curve::coordinate(curve::Channel::Red, 4, curve::Axis::Y);
g.set_param(curve::ID, blue, 0.08);
g.set_param(curve::ID, red, 0.92);
g.set_param(curve::ID, curve::P2_Y, 0.55);
let mut sidecar = Sidecar::new();
sidecar.put(version_of(&g));
let text = sidecar.to_text();
// Keyed by the channel-prefixed id, which is what makes the master's
// unprefixed ones safe to leave alone.
assert!(text.contains("tone_curve.b_p0_y = 0.08"), "{text}");
let parsed = Sidecar::parse(&text).expect("valid");
let mut restored = EditGraph::default_chain();
parsed
.default_version()
.expect("a version")
.apply(&mut restored);
assert_eq!(restored.param(curve::ID, blue), Some(0.08));
assert_eq!(restored.param(curve::ID, red), Some(0.92));
assert_eq!(restored.param(curve::ID, curve::P2_Y), Some(0.55));
assert!(!restored.is_neutral());
}
#[test]
fn an_unknown_operation_survives_a_round_trip() {
// The data-loss case that matters: a device running an older build
// 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);
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);
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);
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("<?xml version=\"1.0\"?>").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);
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);
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);
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);
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);
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);
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");
}
}