Every local adjustment began from a shape: painted, drawn with a handle, or found by a model. So the only way to hold back a sky was to draw a line near where it ended, and the only way to warm skin was to paint round it — both of which put the edit's edge where the photographer put a gesture rather than where the picture changes. A gradient across a treeline halos, and an adjustment traced round a face stops on the outline of a hand. MaskSource grows two variants that select by what a pixel *is*. Luminance carries two bounds on the perceptual tone scale plus a softness; Colour carries an arc of hue, a range of chroma, and one softness for every edge of both. Five floats and three, so they diff, sync and merge per field under FR-NC-9 exactly as a gradient's geometry does — the property a stored raster has none of, and the reason the model's coverage had to sit beside its source rather than inside it. The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the demosaiced source as a sixth binding and two new modes read it: decode, balance, pull a clipped photosite back to neutral, apply the camera matrix, then weigh the band. Nothing crosses to the CPU but the numbers and the matrix, and each mask texel averages its own footprint in the source, so a band lands on the tone an area is rather than on whichever texel a proxy grid happened to land on. The photograph it measures is the one the camera recorded, before this edit. A band over the edited result would slide out from under the edit as the edit was made — raising the highlights would change which pixels counted as highlights, and the slider would chase its own mask. Feather, falloff and morphology stay off a range layer, which is what `shapeable` already meant. All three are functions of the signed distance from a boundary, and a range has no boundary to be at a distance from; its edge is the softness of its own band, in the band's units. Offering them would be four controls that move and change nothing.
2789 lines
111 KiB
Rust
2789 lines
111 KiB
Rust
//! TRACES: FR-DEV-1
|
|
//! Sidecar serialisation — the edit graph as durable, mergeable data.
|
|
//!
|
|
//! # Generic, for the same reason the UI is generic
|
|
//!
|
|
//! `dr-ui` builds its panel by walking [`EditGraph::capabilities`] and never
|
|
//! names an operation (FR-DEV-3c). This module does the same thing to the
|
|
//! same list: it reads every parameter an operation *declares*, and writes
|
|
//! the ones that differ from their default. No operation implements a
|
|
//! serialisation method, and adding one needs no change here — the
|
|
//! descriptor it already publishes for the UI is exactly the description the
|
|
//! sidecar needs.
|
|
//!
|
|
//! That symmetry is the point. There is one place an operation says what its
|
|
//! parameters are, and both the interface and the persistence layer read it.
|
|
//! A third place would be a third thing to forget to update.
|
|
//!
|
|
//! # Only non-default values are written
|
|
//!
|
|
//! An operation at neutral contributes nothing to the file, which is what
|
|
//! makes the format survive both directions of version skew:
|
|
//!
|
|
//! - **Reading an old sidecar in a new build.** An operation added since is
|
|
//! simply absent, and absence means default, which means neutral. The
|
|
//! image renders as it did.
|
|
//! - **Reading a new sidecar in an old build.** An unknown operation's lines
|
|
//! are preserved verbatim (see [`Version::unknown`]) and written back
|
|
//! untouched, so a device running behind cannot silently destroy an edit it
|
|
//! does not understand.
|
|
//!
|
|
//! The alternative — writing every parameter — would make every file grow
|
|
//! with the operation count and would still not solve either case.
|
|
//!
|
|
//! # Why a flat text format rather than serde
|
|
//!
|
|
//! FR-NC-9 requires conflict merge **at the edit-graph node level**: a crop
|
|
//! made on one device and an exposure change made on another must both
|
|
//! survive. In this format a node *is* a line, keyed by `op.param`, so the
|
|
//! merge is a key-wise comparison over two maps ([`Version::merge`]) rather
|
|
//! than a tree diff. A nested document would need the same map built at merge
|
|
//! time anyway.
|
|
//!
|
|
//! It also keeps this crate dependency-free, which is the property that lets
|
|
//! the descriptor and codegen logic be tested without a device (ARCH §6.5a).
|
|
//!
|
|
//! # Shape
|
|
//!
|
|
//! ```text
|
|
//! drsc 1
|
|
//!
|
|
//! [version 8f04c0e2-1f9a-4a63-b0e9-3d1f5a0c77b1]
|
|
//! name = Default
|
|
//! default = 1
|
|
//! revision = 7
|
|
//! device = 3a1c5f80-9d2e-4b11-8c6a-0f7e2d4b9a35
|
|
//! modified = 1754697600
|
|
//! exposure.exposure = 0.75
|
|
//! framing.crop_w = 0.8
|
|
//! ```
|
|
//!
|
|
//! Values are decimal floats; keys are `op_id.param_id`. Both come from the
|
|
//! descriptors, so the file is readable by a human debugging an edit that
|
|
//! went wrong — which is the case that matters, since sidecars are the
|
|
//! authoritative store (ARCH §6.12) and the catalog is the disposable index.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::fmt;
|
|
use std::fmt::Write as _;
|
|
|
|
use crate::coverage::Coverage;
|
|
use crate::graph::EditGraph;
|
|
use crate::mask::{Falloff, 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()
|
|
}
|
|
|
|
/// TRACES: FR-NC-8 | FR-NC-9
|
|
/// The version marked default, or the newest if none is.
|
|
///
|
|
/// # Why this compares revisions rather than taking the first
|
|
///
|
|
/// This used to answer with the first `is_default` in map order, and map
|
|
/// order is *uuid* order. That is only ever right when exactly one version
|
|
/// claims to be the default, and two devices editing one photograph
|
|
/// routinely produce two that do — see [`Self::fuse_default_versions`] for
|
|
/// how they come to exist. With two, the answer became a per-photograph
|
|
/// coin toss decided by which random uuid happened to sort lower, and the
|
|
/// losing device's afternoon of work simply did not appear.
|
|
///
|
|
/// So the same discriminator the merge uses decides it here: `revision`
|
|
/// first, `modified` only to break an exact tie. A device with a skewed
|
|
/// clock cannot win by claiming a later timestamp (FR-NC-8), and the
|
|
/// answer no longer depends on how a uuid sorts.
|
|
///
|
|
/// This is a *reader's* resolution and deliberately not a merge: it picks
|
|
/// a version, it does not combine two. Anything that goes on to write the
|
|
/// file must fuse first, or the edit it did not pick is still sitting
|
|
/// there to be picked next time.
|
|
pub fn default_version(&self) -> Option<&Version> {
|
|
self.versions
|
|
.values()
|
|
.filter(|v| v.is_default)
|
|
.max_by_key(|v| (v.revision, v.modified))
|
|
.or_else(|| self.versions.values().next())
|
|
}
|
|
|
|
/// TRACES: FR-NC-8 | FR-NC-9
|
|
/// Collapse every version claiming to be the default onto one identity.
|
|
///
|
|
/// # The failure this repairs
|
|
///
|
|
/// A version's uuid is the identity a cross-device merge keys on, and for
|
|
/// a long time each device minted its own at random for the same
|
|
/// photograph. Two devices editing one frame therefore wrote two
|
|
/// `[version]` blocks, both marked `default = 1`, and
|
|
/// [`Version::merge`] — which is perfectly capable of combining them —
|
|
/// was never handed the pair, because it matches on uuid and the uuids
|
|
/// never matched. The two edits simply accumulated, and whichever one
|
|
/// [`Self::default_version`] happened to pick was the only one anybody
|
|
/// saw.
|
|
///
|
|
/// Minting the uuid deterministically stops new splits. It does nothing
|
|
/// for the files already written, which is what this is for.
|
|
///
|
|
/// # Why folding into the winner rather than merging in sequence
|
|
///
|
|
/// [`Version::merge`] raises its own `revision` to `max + 1` as it goes,
|
|
/// so merging a chain in ascending order stops being ascending after the
|
|
/// first step and the third version would lose to the accumulator. Folding
|
|
/// *into* the highest `(revision, modified)` avoids the question: every
|
|
/// other version is merged in as the remote and loses every contested
|
|
/// value, while the disjoint case — a key only it holds — is taken
|
|
/// regardless of who wins, which is the whole point of a key-wise merge.
|
|
/// Ratings come across under [`merge_judgement`], so a device that never
|
|
/// judged the frame cannot erase one that did.
|
|
///
|
|
/// The result is the same on every device, because it is a function of the
|
|
/// file's contents alone. Two devices that fuse independently and upload
|
|
/// converge rather than ping-ponging.
|
|
///
|
|
/// # `canonical`
|
|
///
|
|
/// The uuid the result should carry — the caller's derived identity for
|
|
/// this photograph (`dr_catalog::rating::derived_version_uuid`). Passing
|
|
/// `None` folds onto the lexicographically smallest of the defaults, which
|
|
/// is still device-independent and is the rule
|
|
/// `dr_catalog::keywords::fuse_duplicates` already uses for the same shape
|
|
/// of problem.
|
|
///
|
|
/// A rename happens even when there is nothing to fuse: a lone default
|
|
/// still sitting under a randomly minted uuid has to move to the derived
|
|
/// one, or the next write finds no match and adds a *third* version.
|
|
///
|
|
/// Returns the uuid the default now carries, or `None` if there was no
|
|
/// default version to fuse.
|
|
pub fn fuse_default_versions(&mut self, canonical: Option<&str>) -> Option<String> {
|
|
let mut defaults: Vec<String> = self
|
|
.versions
|
|
.values()
|
|
.filter(|v| v.is_default)
|
|
.map(|v| v.uuid.clone())
|
|
.collect();
|
|
defaults.sort();
|
|
let winner_uuid = self
|
|
.versions
|
|
.values()
|
|
.filter(|v| v.is_default)
|
|
.max_by_key(|v| (v.revision, v.modified))
|
|
.map(|v| v.uuid.clone())?;
|
|
|
|
// A canonical uuid that already names a *different*, non-default
|
|
// version is a virtual copy (FR-CAT-12), and renaming onto it would
|
|
// delete it. Vanishingly unlikely — a derived uuid is structurally
|
|
// distinct from a randomly minted one — but silently destroying a
|
|
// named edit is precisely the loss this module exists to prevent, so
|
|
// the rename is declined rather than risked.
|
|
let target = match canonical {
|
|
Some(c) if !defaults.iter().any(|u| u == c) && self.versions.contains_key(c) => {
|
|
log::warn!(
|
|
"not renaming the default version onto {c}: another version already has it"
|
|
);
|
|
winner_uuid.clone()
|
|
}
|
|
Some(c) => c.to_string(),
|
|
None => defaults
|
|
.first()
|
|
.cloned()
|
|
.unwrap_or_else(|| winner_uuid.clone()),
|
|
};
|
|
|
|
let mut winner = self.versions.remove(&winner_uuid)?;
|
|
for uuid in defaults.iter().filter(|u| **u != winner_uuid) {
|
|
if let Some(loser) = self.versions.remove(uuid) {
|
|
winner.merge(&loser, None);
|
|
}
|
|
}
|
|
winner.uuid = target.clone();
|
|
self.versions.insert(target.clone(), winner);
|
|
Some(target)
|
|
}
|
|
|
|
/// Insert or replace a version.
|
|
pub fn put(&mut self, version: Version) {
|
|
self.versions.insert(version.uuid.clone(), version);
|
|
}
|
|
|
|
/// Serialise to the on-disk form.
|
|
///
|
|
/// Deterministic: the same state always produces the same bytes, so a
|
|
/// caller may compare content to decide whether an upload is needed.
|
|
pub fn to_text(&self) -> String {
|
|
let mut out = format!("drsc {FORMAT_VERSION}\n");
|
|
for block in &self.unknown_blocks {
|
|
let _ = writeln!(out, "{block}");
|
|
}
|
|
for v in self.versions.values() {
|
|
let _ = write!(out, "\n[version {}]\n", v.uuid);
|
|
let _ = writeln!(out, "name = {}", v.name);
|
|
if v.is_default {
|
|
let _ = writeln!(out, "default = 1");
|
|
}
|
|
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::Category { signature, name } => {
|
|
let _ = writeln!(out, "signature = {signature}");
|
|
let _ = writeln!(out, "category = {name}");
|
|
}
|
|
MaskSource::Linear {
|
|
centre,
|
|
angle,
|
|
width,
|
|
} => {
|
|
let _ = writeln!(
|
|
out,
|
|
"centre = {} {}",
|
|
format_value(centre.0),
|
|
format_value(centre.1)
|
|
);
|
|
let _ = writeln!(out, "angle = {}", format_value(*angle));
|
|
let _ = writeln!(out, "width = {}", format_value(*width));
|
|
}
|
|
MaskSource::Radial {
|
|
centre,
|
|
radii,
|
|
angle,
|
|
feather,
|
|
} => {
|
|
let _ = writeln!(
|
|
out,
|
|
"centre = {} {}",
|
|
format_value(centre.0),
|
|
format_value(centre.1)
|
|
);
|
|
let _ = writeln!(
|
|
out,
|
|
"radii = {} {}",
|
|
format_value(radii.0),
|
|
format_value(radii.1)
|
|
);
|
|
let _ = writeln!(out, "angle = {}", format_value(*angle));
|
|
let _ = writeln!(out, "feather = {}", format_value(*feather));
|
|
}
|
|
MaskSource::Brush { strokes } => write_strokes(out, strokes),
|
|
// TRACES: FR-DEV-10
|
|
// `band` rather than a key per edge, and the same key for both range
|
|
// sources. The two bounds and their softness are one quantity to a
|
|
// reader — "the band this mask selects" — and splitting them over
|
|
// three lines makes a hand edit that moves one edge and forgets the
|
|
// other look like a file that was written that way.
|
|
MaskSource::Luminance { lo, hi, softness } => {
|
|
let _ = writeln!(
|
|
out,
|
|
"band = {} {} {}",
|
|
format_value(*lo),
|
|
format_value(*hi),
|
|
format_value(*softness)
|
|
);
|
|
}
|
|
MaskSource::Colour {
|
|
hue,
|
|
hue_width,
|
|
chroma_lo,
|
|
chroma_hi,
|
|
softness,
|
|
} => {
|
|
// The chroma half goes out as `band` so that the two range
|
|
// sources share a key for the same idea, and the arc gets its
|
|
// own: a hue is a position on a circle and its neighbours are not
|
|
// bounds in the sense the chroma pair are.
|
|
let _ = writeln!(
|
|
out,
|
|
"hue = {} {}",
|
|
format_value(*hue),
|
|
format_value(*hue_width)
|
|
);
|
|
let _ = writeln!(
|
|
out,
|
|
"band = {} {} {}",
|
|
format_value(*chroma_lo),
|
|
format_value(*chroma_hi),
|
|
format_value(*softness)
|
|
);
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
// Absent means zero, which is the model's own weighting — so a file
|
|
// written before this control existed reads back looking exactly as it did.
|
|
if layer.refine != 0.0 {
|
|
let _ = writeln!(out, "refine = {}", format_value(layer.refine));
|
|
}
|
|
for (op, param, value) in layer.params() {
|
|
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
|
|
}
|
|
|
|
// TRACES: FR-DEV-3 | FR-CAT-8
|
|
// The pixels a model found, so that opening the photograph again — or
|
|
// exporting it from the grid, where no model is ever run — renders the
|
|
// layer instead of silently dropping it. See [`crate::coverage`] for the
|
|
// encoding and for why it is one line.
|
|
//
|
|
// **Last in the block, and that is on purpose.** It is thousands of
|
|
// characters against a dozen elsewhere, and a sidecar is read by hand when
|
|
// an edit has gone wrong (ARCH §6.12); everything a human is looking for
|
|
// should be above it rather than after it.
|
|
//
|
|
// Omitted, not truncated, when it will not encode: a layer with no stored
|
|
// coverage behaves exactly as every layer did before this existed, which
|
|
// is a mask that needs the model run — where a *partial* one would be a
|
|
// mask that is confidently wrong.
|
|
if let Some(coverage) = layer.coverage.as_ref() {
|
|
let _ = writeln!(out, "coverage = {}", coverage.to_text());
|
|
}
|
|
}
|
|
|
|
/// Write a brush layer's strokes, one line each.
|
|
///
|
|
/// A line per stroke, in the order they were painted, because the order *is*
|
|
/// the mask: an erase after an add removes it and the same pair reversed does
|
|
/// not. It is also the granularity anyone reading a diff wants — a stroke is
|
|
/// what the user made and what an undo takes back. A line per point would bury
|
|
/// the rest of the file, and one line for the whole layer would make adding a
|
|
/// stroke look like the entire mask had been rewritten.
|
|
///
|
|
/// Points are `x,y` pairs rather than a flat run of numbers. A truncated or
|
|
/// hand-edited line would otherwise shift every coordinate by one and land the
|
|
/// mask somewhere else entirely, which is the failure that looks like the
|
|
/// software forgot the edit rather than like a damaged file.
|
|
fn write_strokes(out: &mut String, strokes: &[Stroke]) {
|
|
for stroke in strokes {
|
|
let _ = write!(
|
|
out,
|
|
"stroke = {} {} {} {}",
|
|
if stroke.erase { "erase" } else { "add" },
|
|
format_value(stroke.radius),
|
|
format_value(stroke.hardness),
|
|
format_value(stroke.flow),
|
|
);
|
|
for (x, y) in &stroke.points {
|
|
let _ = write!(out, " {},{}", format_value(*x), format_value(*y));
|
|
}
|
|
let _ = writeln!(out);
|
|
}
|
|
}
|
|
|
|
/// Read one `stroke = …` line, or nothing if it cannot be trusted.
|
|
///
|
|
/// A malformed stroke costs that stroke and not the layer. Refusing the whole
|
|
/// block would throw away every other stroke on it over one bad line, and
|
|
/// guessing at the missing half would put paint somewhere the user never
|
|
/// touched — which of the three is worst depends on the line, but a wrong mask
|
|
/// is the only one that looks like it worked.
|
|
fn parse_stroke(value: &str) -> Option<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,
|
|
category: String,
|
|
score: f32,
|
|
centre: (f32, f32),
|
|
radii: (f32, f32),
|
|
angle: f32,
|
|
width: f32,
|
|
feather: f32,
|
|
/// TRACES: FR-DEV-10
|
|
/// A range source's band: its two bounds and the fade at each edge.
|
|
///
|
|
/// One triple for both range kinds — tone positions for a luminance
|
|
/// layer, chroma for a colour one — because they are the same line in the
|
|
/// file and reading them into two sets of fields would be two ways to
|
|
/// spell one thing.
|
|
band: (f32, f32, f32),
|
|
/// A colour range's arc: centre and half-width, in turns.
|
|
hue: (f32, f32),
|
|
invert: bool,
|
|
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,
|
|
refine: f32,
|
|
coverage: Option<Coverage>,
|
|
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,
|
|
category: String::new(),
|
|
class: String::new(),
|
|
score: 0.0,
|
|
centre: (0.5, 0.5),
|
|
radii: (0.25, 0.25),
|
|
angle: 0.0,
|
|
width: 0.0,
|
|
feather: 0.0,
|
|
// The defaults a new layer gets, so a block that names a range
|
|
// source and nothing else reads back as the mask the panel would
|
|
// have made rather than as an empty band selecting nothing.
|
|
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
|
|
hue: (0.06, 0.05),
|
|
invert: false,
|
|
opacity: 1.0,
|
|
enabled: true,
|
|
edge_feather: DEFAULT_FEATHER,
|
|
falloff: Falloff::default(),
|
|
morphology: Morphology::default(),
|
|
morph_radius: 0.0,
|
|
refine: 0.0,
|
|
coverage: None,
|
|
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();
|
|
}
|
|
// A category's own key rather than reusing `class`: both name a
|
|
// thing the mask covers, but one is a COCO instance's label and
|
|
// the other an entry in the scene descriptor, and a file that
|
|
// conflated them would round-trip a subject into a category.
|
|
"category" => self.category = value.to_string(),
|
|
"index" => self.index = value.parse().unwrap_or(0),
|
|
"class" => self.class = value.to_string(),
|
|
"score" => self.score = value.parse().unwrap_or(0.0),
|
|
"centre" => self.centre = pair(value).unwrap_or(self.centre),
|
|
"radii" => self.radii = pair(value).unwrap_or(self.radii),
|
|
"angle" => self.angle = value.parse().unwrap_or(0.0),
|
|
"width" => self.width = value.parse().unwrap_or(0.0),
|
|
"feather" => self.feather = value.parse().unwrap_or(0.0),
|
|
// TRACES: FR-DEV-10
|
|
// Kept as written and ordered later, in `MaskSource::*_range`.
|
|
// Clamping here as well would be a second place the band's
|
|
// invariants live, and the two would drift.
|
|
"band" => self.band = triple(value).unwrap_or(self.band),
|
|
"hue" => self.hue = pair(value).unwrap_or(self.hue),
|
|
// A cache, so an unreadable one is dropped rather than refused:
|
|
// the layer still says what it selects, and the worst a `None`
|
|
// here costs is a model run. Refusing the layer over it would
|
|
// throw away an edit to protect a copy of something reproducible.
|
|
"coverage" => {
|
|
self.coverage = Coverage::parse(value);
|
|
if self.coverage.is_none() {
|
|
log::warn!(
|
|
"sidecar: mask {} has unreadable coverage; it will need the model run",
|
|
self.id
|
|
);
|
|
}
|
|
}
|
|
// Appended rather than assigned: a brush layer is a list of these,
|
|
// and the file's line order is the order they were painted in.
|
|
"stroke" => self.strokes.extend(parse_stroke(value)),
|
|
"invert" => self.invert = value != "0",
|
|
"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)
|
|
}
|
|
// Clamped for the same reason the feather is: a strictness off the
|
|
// end of the scale is a category that has deleted itself, and a
|
|
// file is not trusted to ask for that.
|
|
"refine" => {
|
|
self.refine = value
|
|
.parse::<f32>()
|
|
.unwrap_or(0.0)
|
|
.clamp(0.0, crate::mask::MAX_REFINE)
|
|
}
|
|
// An unrecognised name falls back to the default rather than
|
|
// dropping the layer. A newer build's falloff curve is a cosmetic
|
|
// difference in the edge; losing the selection under it would not
|
|
// be cosmetic.
|
|
"edge-falloff" => {
|
|
self.falloff = Falloff::from_name(value).unwrap_or_else(|| {
|
|
log::warn!("sidecar: unknown falloff '{value}'; using the default");
|
|
Falloff::default()
|
|
})
|
|
}
|
|
"morphology" => {
|
|
self.morphology = Morphology::from_name(value).unwrap_or_else(|| {
|
|
log::warn!("sidecar: unknown morphology '{value}'; using none");
|
|
Morphology::default()
|
|
})
|
|
}
|
|
_ => 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,
|
|
},
|
|
"category" => MaskSource::Category {
|
|
signature: self.signature,
|
|
name: self.category,
|
|
},
|
|
"linear" => MaskSource::Linear {
|
|
centre: self.centre,
|
|
angle: self.angle,
|
|
width: self.width,
|
|
},
|
|
"radial" => MaskSource::Radial {
|
|
centre: self.centre,
|
|
radii: self.radii,
|
|
angle: self.angle,
|
|
feather: self.feather,
|
|
},
|
|
"brush" => MaskSource::Brush {
|
|
strokes: self.strokes,
|
|
},
|
|
// TRACES: FR-DEV-10
|
|
// Through the constructors, not built by hand: they are where the
|
|
// band's invariants are kept, and a file is exactly the input
|
|
// that has not been through them.
|
|
"luminance" => MaskSource::luminance_range(self.band.0, self.band.1, self.band.2),
|
|
"colour" => MaskSource::colour_range(
|
|
self.hue.0,
|
|
self.hue.1,
|
|
self.band.0,
|
|
self.band.1,
|
|
self.band.2,
|
|
),
|
|
other => {
|
|
log::warn!(
|
|
"sidecar: unknown mask source '{other}'; skipping 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;
|
|
layer.refine = self.refine;
|
|
// Only where there is a model behind the layer to have produced it. A
|
|
// gradient or a brush that arrived carrying one is a file that has
|
|
// been hand-edited or written by a build that means something else by
|
|
// the key, and honouring it would upload a raster nothing samples.
|
|
layer.coverage = match layer.source {
|
|
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
|
self.coverage.map(std::sync::Arc::new)
|
|
}
|
|
_ => None,
|
|
};
|
|
for (op, param, value) in &self.params {
|
|
// `ParamId` holds a `&'static str` and this one came off disk, so
|
|
// it is matched against the descriptors and the *static* id is
|
|
// what reaches the operation — exactly what `resolve` does for the
|
|
// global chain.
|
|
let Some(id) = layer
|
|
.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id.0 == op)
|
|
// The descriptor is bound inside the closure rather than
|
|
// chained through: it is an owned `Arc` now, so a
|
|
// `ParamDescriptor` borrowed out of it would not outlive the
|
|
// expression. The `ParamId` is `Copy`, so it does.
|
|
.and_then(|o| {
|
|
o.descriptor()
|
|
.params
|
|
.iter()
|
|
.find(|p| p.id.0 == param)
|
|
.map(|p| p.id)
|
|
})
|
|
else {
|
|
log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring");
|
|
continue;
|
|
};
|
|
layer.set_param(op, id, *value);
|
|
}
|
|
|
|
Some((self.version, layer))
|
|
}
|
|
}
|
|
|
|
/// Two whitespace-separated floats.
|
|
fn pair(value: &str) -> Option<(f32, f32)> {
|
|
let mut it = value.split_whitespace();
|
|
let a: f32 = it.next()?.parse().ok()?;
|
|
let b: f32 = it.next()?.parse().ok()?;
|
|
// A NaN parses — `"nan"` is a valid float — and then survives every clamp
|
|
// downstream, because every comparison against it is false. It reaches a
|
|
// uniform, and a mask whose geometry or band is NaN selects nothing while
|
|
// looking, in the file and in the panel, exactly like one that should.
|
|
(a.is_finite() && b.is_finite()).then_some((a, b))
|
|
}
|
|
|
|
/// TRACES: FR-DEV-10
|
|
/// Three whitespace-separated floats — a range mask's band.
|
|
fn triple(value: &str) -> Option<(f32, f32, f32)> {
|
|
let mut it = value.split_whitespace();
|
|
let a: f32 = it.next()?.parse().ok()?;
|
|
let b: f32 = it.next()?.parse().ok()?;
|
|
let c: f32 = it.next()?.parse().ok()?;
|
|
(a.is_finite() && b.is_finite() && c.is_finite()).then_some((a, b, c))
|
|
}
|
|
|
|
/// Format a value without a trailing `.0` on whole numbers, and without
|
|
/// exponent notation — both so the file stays diffable and hand-readable.
|
|
fn format_value(v: f32) -> String {
|
|
let mut s = format!("{v:.6}");
|
|
if s.contains('.') {
|
|
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
|
}
|
|
if s == "-0" {
|
|
s = "0".to_string();
|
|
}
|
|
s
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ParseError {
|
|
/// The header line was missing or not a `drsc` header.
|
|
NotASidecar,
|
|
/// Written by a newer build, in a format this one cannot read.
|
|
UnsupportedVersion(u32),
|
|
}
|
|
|
|
impl fmt::Display for ParseError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::NotASidecar => f.write_str("not a DarkRoom sidecar"),
|
|
Self::UnsupportedVersion(v) => {
|
|
write!(f, "sidecar format version {v} is newer than this build")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ParseError {}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::framing;
|
|
use crate::ops::{curve, exposure, saturation, white_balance};
|
|
|
|
fn edited() -> EditGraph {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
|
|
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
|
|
g
|
|
}
|
|
|
|
fn version_of(graph: &EditGraph) -> Version {
|
|
Version::from_graph("uuid-1", "Default", graph)
|
|
}
|
|
|
|
/// A graph developing on a stock, with tables well-formed enough for the
|
|
/// film node to keep them. The emulsion is invented; what is under test
|
|
/// is whether the *choice* reaches the file.
|
|
fn on_film(stock: &str) -> EditGraph {
|
|
let mut g = edited();
|
|
g.set_film(Some(crate::graph::Film {
|
|
stock: stock.to_string(),
|
|
print: None,
|
|
tables: crate::ops::FilmTables {
|
|
exposure_matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
|
|
curves: vec![[0.5, 0.5, 0.5]; crate::ops::film_sim::CURVE_SAMPLES],
|
|
curve_log_min: -3.0,
|
|
curve_log_max: 1.0,
|
|
lut: vec![[0.5, 0.5, 0.5]; 8],
|
|
density_max: 2.0,
|
|
lut_size: 2,
|
|
grain_particles: [0.0; 3],
|
|
grain_density_max: [2.0; 3],
|
|
grain_uniformity: 1.0,
|
|
},
|
|
}));
|
|
g
|
|
}
|
|
|
|
#[test]
|
|
fn saving_an_edit_keeps_the_film_it_was_developed_on() {
|
|
// `update` is the write path — the one an automatic save goes through
|
|
// — and it used to copy the parameters and the masks and say nothing
|
|
// about the film. A photograph developed on a stock was written back
|
|
// without it, so the next time it opened, the emulsion was gone and
|
|
// nothing had reported a failure.
|
|
//
|
|
// It reads as an oversight because it was one, and that is the point:
|
|
// three routines captured "the edit" and each captured a different
|
|
// subset. All three now destructure one `EditState`, so the next part
|
|
// of an edit cannot be forgotten by anybody writing a line too few.
|
|
let mut v = version_of(&on_film("kodak_portra_400"));
|
|
v.film = None;
|
|
|
|
v.update(&on_film("kodak_portra_400"), "device-a", 1000);
|
|
|
|
assert_eq!(
|
|
v.film,
|
|
Some(FilmRef {
|
|
stock: "kodak_portra_400".into(),
|
|
print: None,
|
|
}),
|
|
"the stock did not survive the save"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_film_written_by_update_comes_back_off_the_disk() {
|
|
// End to end, because the field being set is only half of it: the
|
|
// stock has to reach the text and parse back out of it.
|
|
let mut v = version_of(&EditGraph::default_chain());
|
|
v.update(&on_film("ilford_hp5"), "device-a", 1000);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(v);
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("re-read");
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
let rebake = parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut graph);
|
|
|
|
assert_eq!(
|
|
rebake.wanted().map(|f| f.stock.as_str()),
|
|
Some("ilford_hp5"),
|
|
"reopening the photograph has to ask for its stock back"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_non_default_values_are_written() {
|
|
// The property the whole format rests on: a neutral operation is
|
|
// absent, so a file stays small and an operation added later reads
|
|
// as neutral rather than as missing.
|
|
let v = version_of(&edited());
|
|
assert_eq!(v.params.len(), 2);
|
|
assert!(v
|
|
.params
|
|
.contains_key(&("exposure".into(), "exposure".into())));
|
|
assert!(!v.params.keys().any(|(op, _)| op == saturation::ID.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_neutral_graph_writes_no_parameters() {
|
|
let v = version_of(&EditGraph::default_chain());
|
|
assert!(v.params.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_graph_round_trips_through_text() {
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&edited()));
|
|
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored)
|
|
.expect_no_film();
|
|
|
|
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
|
assert_eq!(
|
|
restored.param(white_balance::ID, white_balance::TEMPERATURE),
|
|
Some(30.0)
|
|
);
|
|
assert!(!restored.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn every_parameter_in_the_chain_round_trips() {
|
|
// The generic claim, asserted against the whole chain rather than a
|
|
// sample: if an operation needed special handling to persist, this
|
|
// is where it would fail.
|
|
let mut g = EditGraph::default_chain();
|
|
for cap in g.capabilities() {
|
|
for p in &cap.params {
|
|
if let crate::ParamKind::Scalar { max, precision, .. } = p.kind {
|
|
let step = 10f32.powi(i32::from(precision));
|
|
let target = (max * 0.5 * step).round() / step;
|
|
g.set_param(cap.id, p.id, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&g));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored)
|
|
.expect_no_film();
|
|
|
|
for cap in g.capabilities() {
|
|
for p in &cap.params {
|
|
assert_eq!(
|
|
restored.param(cap.id, p.id),
|
|
Some(p.value),
|
|
"{}.{} did not survive the sidecar",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn framing_survives_the_round_trip() {
|
|
// Framing is not an `Operation`, so it is exactly the stage a
|
|
// serialiser written against the op list alone would silently drop.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_crop(crate::CropRect {
|
|
x: 0.1,
|
|
y: 0.2,
|
|
width: 0.5,
|
|
height: 0.6,
|
|
});
|
|
g.set_param(framing::ID, framing::ANGLE, -1.5);
|
|
g.rotate_quarters(1);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&g));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored)
|
|
.expect_no_film();
|
|
|
|
assert_eq!(restored.crop(), g.crop());
|
|
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
|
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_sidecar_neither_records_nor_erases_the_files_orientation() {
|
|
// How a file stored its pixels is a fact about the file, so it must
|
|
// not travel in the sidecar — a shared edit would then carry one
|
|
// camera's sensor scan onto another's. Two failures are checked
|
|
// together because they are the same mistake seen from each end.
|
|
let mut sideways = EditGraph::default_chain();
|
|
sideways.set_orientation(dr_types::Orientation::from_exif(6));
|
|
|
|
// Nothing was edited, so there is nothing to write. If the baseline
|
|
// leaked into `param`, a rotation would appear here.
|
|
let v = version_of(&sideways);
|
|
let text = {
|
|
let mut s = Sidecar::new();
|
|
s.put(v);
|
|
s.to_text()
|
|
};
|
|
assert!(
|
|
!text.contains("framing.rotation"),
|
|
"an untouched sideways file wrote a rotation:\n{text}"
|
|
);
|
|
|
|
// And applying an edit — which resets the graph first — must leave the
|
|
// orientation where it was, or reopening an edited portrait frame
|
|
// shows it on its side.
|
|
let mut edited = EditGraph::default_chain();
|
|
edited.set_param(framing::ID, framing::ANGLE, -1.5);
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&edited));
|
|
Sidecar::parse(&sidecar.to_text())
|
|
.expect("valid")
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut sideways)
|
|
.expect_no_film();
|
|
|
|
assert_eq!(
|
|
sideways.framing().baseline(),
|
|
dr_types::Orientation::from_exif(6)
|
|
);
|
|
assert_eq!(sideways.output_size(6000, 4000), (4000, 6000));
|
|
}
|
|
|
|
#[test]
|
|
fn applying_a_version_replaces_rather_than_overlays() {
|
|
// Loading an edit onto a graph that already holds one must not leave
|
|
// the previous image's exposure behind.
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&EditGraph::default_chain()));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut g = edited();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut g)
|
|
.expect_no_film();
|
|
assert!(g.is_neutral(), "a neutral version must clear the graph");
|
|
}
|
|
|
|
#[test]
|
|
fn writing_the_same_state_twice_is_byte_identical() {
|
|
// What lets a caller skip an upload by comparing content.
|
|
let mut a = Sidecar::new();
|
|
a.put(version_of(&edited()));
|
|
let once = a.to_text();
|
|
let twice = Sidecar::parse(&once).expect("valid").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
|
/// A file written before the tone curve had per-channel curves.
|
|
///
|
|
/// Spelled out as literal text rather than produced by `to_text`, because
|
|
/// the claim is about *those bytes*: a sidecar generated by this build
|
|
/// would agree with this build by construction, and would go on agreeing
|
|
/// with it through a rename that broke every file on disk.
|
|
#[test]
|
|
fn a_sidecar_from_before_the_channel_curves_still_names_the_master() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 4\nmodified = 9\n\
|
|
tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut g)
|
|
.expect_no_film();
|
|
|
|
// The S-curve the file describes, on the master curve and nowhere
|
|
// else.
|
|
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
|
|
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
|
|
for channel in [
|
|
curve::Channel::Red,
|
|
curve::Channel::Green,
|
|
curve::Channel::Blue,
|
|
] {
|
|
for point in 0..curve::POINTS {
|
|
for axis in [curve::Axis::X, curve::Axis::Y] {
|
|
let id = curve::coordinate(channel, point, axis);
|
|
let expected = g
|
|
.capabilities()
|
|
.iter()
|
|
.find(|c| c.id == curve::ID)
|
|
.and_then(|c| c.params.iter().find(|p| p.id == id))
|
|
.map(|p| p.default);
|
|
assert_eq!(
|
|
g.param(curve::ID, id),
|
|
expected,
|
|
"{id} moved, and no line in the file mentions it"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// And writing it back produces the same two lines: the curves the file
|
|
// never mentioned are still at their defaults, so they are still
|
|
// absent (`only_non_default_values_are_written`).
|
|
let written = Sidecar::parse(&Sidecar::parse(text).expect("valid").to_text())
|
|
.expect("valid")
|
|
.to_text();
|
|
assert!(written.contains("tone_curve.p1_y = 0.15"), "{written}");
|
|
assert!(written.contains("tone_curve.p3_y = 0.85"), "{written}");
|
|
assert!(
|
|
!written.contains("tone_curve.r_"),
|
|
"an untouched channel curve was written out:\n{written}"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The other direction: the new curves persist like any other parameter.
|
|
#[test]
|
|
fn a_per_channel_curve_survives_the_round_trip() {
|
|
let mut g = EditGraph::default_chain();
|
|
// A faded shadow: blue lifted at the black point, red pulled down.
|
|
let blue = curve::coordinate(curve::Channel::Blue, 0, curve::Axis::Y);
|
|
let red = curve::coordinate(curve::Channel::Red, 4, curve::Axis::Y);
|
|
g.set_param(curve::ID, blue, 0.08);
|
|
g.set_param(curve::ID, red, 0.92);
|
|
g.set_param(curve::ID, curve::P2_Y, 0.55);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&g));
|
|
let text = sidecar.to_text();
|
|
// Keyed by the channel-prefixed id, which is what makes the master's
|
|
// unprefixed ones safe to leave alone.
|
|
assert!(text.contains("tone_curve.b_p0_y = 0.08"), "{text}");
|
|
|
|
let parsed = Sidecar::parse(&text).expect("valid");
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored)
|
|
.expect_no_film();
|
|
|
|
assert_eq!(restored.param(curve::ID, blue), Some(0.08));
|
|
assert_eq!(restored.param(curve::ID, red), Some(0.92));
|
|
assert_eq!(restored.param(curve::ID, curve::P2_Y), Some(0.55));
|
|
assert!(!restored.is_neutral());
|
|
}
|
|
|
|
/// TRACES: FR-PLG-8
|
|
/// FR-PLG-8's preservation clause, and only that clause.
|
|
///
|
|
/// The requirement opens by saying the sidecar "already preserves lines it
|
|
/// does not understand verbatim and writes them back untouched", and that
|
|
/// the property "is now load-bearing and shall be treated as such". This is
|
|
/// the test that makes it load-bearing rather than incidental.
|
|
///
|
|
/// **It is weaker than the requirement's stated acceptance criterion**,
|
|
/// which is that the re-saved file be *byte-identical* to the original.
|
|
/// This asserts `contains`. The two halves of that criterion exist
|
|
/// separately — `writing_the_same_state_twice_is_byte_identical` proves
|
|
/// byte identity for content this build understands, and this proves
|
|
/// survival for content it does not — and nothing yet joins them into the
|
|
/// one claim FR-PLG-8 makes. Everything else the requirement asks for
|
|
/// (aggregated alerts, the export gate, rendering without rather than
|
|
/// guessing) is unbuilt, which is why the tag is on these two tests and
|
|
/// not on the module.
|
|
#[test]
|
|
fn an_unknown_operation_survives_a_round_trip() {
|
|
// The data-loss case that matters: a device running an older build
|
|
// opens a file written by a newer one, saves, and must not delete
|
|
// the operation it never understood.
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 3\nmodified = 5\n\
|
|
exposure.exposure = 0.5\ntime_machine.year = 1994\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let written = parsed.to_text();
|
|
assert!(
|
|
written.contains("time_machine.year = 1994"),
|
|
"an unknown operation must not be dropped:\n{written}"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-PLG-8
|
|
/// The other half of preservation: kept in the file, kept out of the edit.
|
|
///
|
|
/// "Render without, never render a guess" is a separate clause, and this is
|
|
/// not it — that one is about a *recognised* operation whose plugin is
|
|
/// missing. This is the narrower claim that an unparsed line cannot reach
|
|
/// the graph by accident, which is what makes preserving it safe.
|
|
#[test]
|
|
fn an_unknown_operation_does_not_reach_the_graph() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
time_machine.year = 1994\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut g)
|
|
.expect_no_film();
|
|
assert!(g.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_value_costs_its_line_and_not_the_file() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
exposure.exposure = NaN\nwhite_balance.temperature = 20\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut g)
|
|
.expect_no_film();
|
|
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
|
assert_eq!(
|
|
g.param(white_balance::ID, white_balance::TEMPERATURE),
|
|
Some(20.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
|
|
// A sidecar written by a build with a wider range must not put an
|
|
// out-of-range value into a uniform.
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
exposure.exposure = 99\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut g)
|
|
.expect_no_film();
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_newer_format_version_is_refused_rather_than_guessed() {
|
|
let err = Sidecar::parse("drsc 99\n").unwrap_err();
|
|
assert_eq!(err, ParseError::UnsupportedVersion(99));
|
|
}
|
|
|
|
#[test]
|
|
fn a_non_sidecar_is_rejected() {
|
|
assert_eq!(
|
|
Sidecar::parse("<?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");
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-NC-8 | FR-NC-9
|
|
/// Two devices, one photograph, two `default = 1` versions.
|
|
///
|
|
/// The state a library reaches when each device mints its own version uuid.
|
|
/// These cover both halves of the resulting failure: which version a reader
|
|
/// picks, and whether the two ever converge.
|
|
#[cfg(test)]
|
|
mod split_defaults {
|
|
use super::*;
|
|
|
|
/// A default version as a device that had never heard of the other would
|
|
/// write it.
|
|
fn default_version(uuid: &str, revision: u64, modified: i64, rating: u8) -> Version {
|
|
Version {
|
|
uuid: uuid.to_string(),
|
|
name: "Default".to_string(),
|
|
is_default: true,
|
|
revision,
|
|
modified,
|
|
rating,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn with(versions: Vec<Version>) -> Sidecar {
|
|
let mut s = Sidecar::new();
|
|
for v in versions {
|
|
s.put(v);
|
|
}
|
|
s
|
|
}
|
|
|
|
/// The observed failure, reduced. Both blocks are `default = 1`; the older
|
|
/// one sorts first by uuid, and the reader used to answer with it — so a
|
|
/// rating made on the second device was invisible on the first.
|
|
#[test]
|
|
fn the_newer_edit_wins_even_when_its_uuid_sorts_later() {
|
|
let s = with(vec![
|
|
default_version("0545c20a", 1, 1_787_337_630, 4),
|
|
default_version("679fe872", 2, 1_788_092_930, 1),
|
|
]);
|
|
assert_eq!(s.default_version().unwrap().uuid, "679fe872");
|
|
assert_eq!(s.default_version().unwrap().rating, 1);
|
|
}
|
|
|
|
/// The same file with the uuids the other way round. The answer must
|
|
/// follow the revision, not the ordering — otherwise the test above only
|
|
/// passes by luck.
|
|
#[test]
|
|
fn the_newer_edit_wins_even_when_its_uuid_sorts_earlier() {
|
|
let s = with(vec![
|
|
default_version("0545c20a", 2, 1_788_092_930, 1),
|
|
default_version("679fe872", 1, 1_787_337_630, 4),
|
|
]);
|
|
assert_eq!(s.default_version().unwrap().uuid, "0545c20a");
|
|
assert_eq!(s.default_version().unwrap().rating, 1);
|
|
}
|
|
|
|
/// A skewed clock must not beat a real edit — the FR-NC-8 rule, applied at
|
|
/// the reader as well as at the merge.
|
|
#[test]
|
|
fn a_later_timestamp_cannot_beat_a_higher_revision() {
|
|
let s = with(vec![
|
|
default_version("aaaa", 5, 1_000, 3),
|
|
default_version("bbbb", 2, 9_999_999, 1),
|
|
]);
|
|
assert_eq!(s.default_version().unwrap().rating, 3);
|
|
}
|
|
|
|
/// Fusing is what makes the two converge rather than merely choosing
|
|
/// between them: a parameter only the *losing* version holds survives,
|
|
/// because the merge is key-wise.
|
|
#[test]
|
|
fn fusing_keeps_the_disjoint_work_of_both_devices() {
|
|
let mut old = default_version("0545c20a", 1, 100, 4);
|
|
old.params
|
|
.insert(("exposure".into(), "exposure".into()), 0.75);
|
|
let mut new = default_version("679fe872", 2, 200, 0);
|
|
new.params
|
|
.insert(("saturation".into(), "saturation".into()), 0.5);
|
|
|
|
let mut s = with(vec![old, new]);
|
|
let uuid = s.fuse_default_versions(None).unwrap();
|
|
|
|
assert_eq!(s.versions.len(), 1, "the split must be closed");
|
|
let v = &s.versions[&uuid];
|
|
assert_eq!(
|
|
v.params.get(&("exposure".into(), "exposure".into())),
|
|
Some(&0.75),
|
|
"the older device's exposure was dropped"
|
|
);
|
|
assert_eq!(
|
|
v.params.get(&("saturation".into(), "saturation".into())),
|
|
Some(&0.5),
|
|
"the newer device's saturation was dropped"
|
|
);
|
|
// A judgement the loser holds and the winner does not is adopted, for
|
|
// the reason `merge_judgement` gives: a zero is "never judged".
|
|
assert_eq!(v.rating, 4);
|
|
}
|
|
|
|
/// A contested value resolves to the higher revision, not to whichever
|
|
/// uuid sorted first.
|
|
#[test]
|
|
fn fusing_resolves_a_contested_value_by_revision() {
|
|
let mut old = default_version("aaaa", 9, 100, 0);
|
|
old.params
|
|
.insert(("exposure".into(), "exposure".into()), 0.75);
|
|
let mut new = default_version("bbbb", 1, 999_999, 0);
|
|
new.params
|
|
.insert(("exposure".into(), "exposure".into()), -2.0);
|
|
|
|
let mut s = with(vec![old, new]);
|
|
let uuid = s.fuse_default_versions(None).unwrap();
|
|
assert_eq!(
|
|
s.versions[&uuid]
|
|
.params
|
|
.get(&("exposure".into(), "exposure".into())),
|
|
Some(&0.75),
|
|
"the higher revision must win"
|
|
);
|
|
}
|
|
|
|
/// Every device must reach the same file from the same input, or two of
|
|
/// them fuse and upload forever without converging.
|
|
#[test]
|
|
fn fusing_is_the_same_on_every_device() {
|
|
let versions = vec![
|
|
default_version("cccc", 1, 100, 2),
|
|
default_version("aaaa", 3, 300, 0),
|
|
default_version("bbbb", 2, 200, 0),
|
|
];
|
|
let mut one = with(versions.clone());
|
|
// The other device parsed the same file, so it holds the same set in a
|
|
// different insertion order.
|
|
let mut two = with(versions.into_iter().rev().collect());
|
|
|
|
one.fuse_default_versions(None);
|
|
two.fuse_default_versions(None);
|
|
assert_eq!(one.to_text(), two.to_text());
|
|
assert_eq!(one.versions.len(), 1);
|
|
}
|
|
|
|
/// Three or more versions must all fold in. The accumulator's revision
|
|
/// climbs as it merges, so folding a chain in ascending order would drop
|
|
/// everything after the second — which is why the fold goes into the
|
|
/// winner instead.
|
|
#[test]
|
|
fn a_third_device_is_not_lost_to_the_accumulators_rising_revision() {
|
|
let mut a = default_version("aaaa", 1, 100, 0);
|
|
a.params.insert(("a".into(), "a".into()), 1.0);
|
|
let mut b = default_version("bbbb", 2, 200, 0);
|
|
b.params.insert(("b".into(), "b".into()), 2.0);
|
|
let mut c = default_version("cccc", 3, 300, 0);
|
|
c.params.insert(("c".into(), "c".into()), 3.0);
|
|
|
|
let mut s = with(vec![a, b, c]);
|
|
let uuid = s.fuse_default_versions(None).unwrap();
|
|
let v = &s.versions[&uuid];
|
|
for key in ["a", "b", "c"] {
|
|
assert!(
|
|
v.params.contains_key(&(key.to_string(), key.to_string())),
|
|
"{key} was dropped by the fold"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The rename half. A lone default under a randomly minted uuid has to
|
|
/// move onto the derived one, or the next write matches nothing and adds a
|
|
/// third version instead of amending this one.
|
|
#[test]
|
|
fn a_lone_default_is_renamed_onto_the_canonical_uuid() {
|
|
let mut s = with(vec![default_version("random-v4", 1, 100, 3)]);
|
|
let uuid = s.fuse_default_versions(Some("derived")).unwrap();
|
|
|
|
assert_eq!(uuid, "derived");
|
|
assert_eq!(s.versions.len(), 1);
|
|
assert_eq!(s.versions["derived"].rating, 3, "the edit went with it");
|
|
}
|
|
|
|
/// A virtual copy (FR-CAT-12) that happens to hold the canonical uuid must
|
|
/// not be overwritten by the rename.
|
|
#[test]
|
|
fn a_named_version_is_not_destroyed_by_the_rename() {
|
|
let mut copy = default_version("derived", 1, 100, 5);
|
|
copy.is_default = false;
|
|
copy.name = "For print".to_string();
|
|
|
|
let mut s = with(vec![default_version("random-v4", 1, 100, 3), copy]);
|
|
let uuid = s.fuse_default_versions(Some("derived")).unwrap();
|
|
|
|
assert_eq!(uuid, "random-v4", "the rename must be declined");
|
|
assert_eq!(s.versions.len(), 2);
|
|
assert_eq!(s.versions["derived"].name, "For print");
|
|
}
|
|
|
|
/// Fusing a file that never needed it must not rewrite it — a sidecar that
|
|
/// changes on every read is a sidecar that uploads on every read.
|
|
#[test]
|
|
fn fusing_a_healthy_sidecar_changes_nothing() {
|
|
let mut s = with(vec![default_version("only", 4, 400, 2)]);
|
|
let before = s.to_text();
|
|
s.fuse_default_versions(None);
|
|
assert_eq!(s.to_text(), before);
|
|
}
|
|
|
|
/// Nothing to fuse is not a failure — most photographs have never been
|
|
/// edited on any device.
|
|
#[test]
|
|
fn an_empty_sidecar_has_no_default_to_fuse() {
|
|
let mut s = Sidecar::new();
|
|
assert_eq!(s.fuse_default_versions(Some("derived")), None);
|
|
assert!(s.versions.is_empty());
|
|
}
|
|
}
|