`Version::update` is the write path an automatic save goes through. It copied the parameters and the masks and said nothing about the film, so a photograph developed on a stock was written back without it and opened the next time without its emulsion. Nothing reported a failure — the line was simply not there. It is the third of the three routines that captured "the edit" and the only one that got it wrong, which is the argument for not having three. All of them now destructure one `EditState`, so `from_graph`, `update` and `apply` cannot disagree about what an edit consists of, and the next part of one cannot be lost by anybody writing a line too few. Two tests, both of which fail without the fix: the field survives `update`, and the stock survives the round trip through the file. `apply` returns the `FilmRebake` it always implicitly owed, so `apply_version` now reads the debt off the call rather than off `version.film` — and pays it in both directions, since a version with no film has to clear the adjust pass too or it keeps textures bound that nothing will sample. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2257 lines
88 KiB
Rust
2257 lines
88 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::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<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.
|
|
///
|
|
/// 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<FilmRef>,
|
|
/// TRACES: FR-DEV-8 | FR-NC-9
|
|
/// The repairs (`docs/spot-removal.md`).
|
|
///
|
|
/// A line per spot, keyed `spot.<id>`, 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<String, String>,
|
|
}
|
|
|
|
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<String>, name: impl Into<String>, 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<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
|
|
}
|
|
|
|
/// 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<String> = self
|
|
.spots
|
|
.spots()
|
|
.iter()
|
|
.chain(remote.spots.spots())
|
|
.map(|s| s.id.clone())
|
|
.collect::<std::collections::BTreeSet<_>>()
|
|
.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::<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));
|
|
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<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),
|
|
// 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::<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)
|
|
}
|
|
}
|
|
|
|
/// 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.<id> = …` 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<Spot> {
|
|
if id.is_empty() {
|
|
return None;
|
|
}
|
|
let mut tokens = value.split_whitespace();
|
|
let mut number = || {
|
|
tokens
|
|
.next()
|
|
.and_then(|t| t.parse::<f32>().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<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)
|
|
}
|
|
|
|
/// 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("<?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).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");
|
|
}
|
|
}
|