The last line of FR-DEV-3, and the mask ARCH §5.4 was written for. darktable rasterises drawn masks on the CPU and users call the result unworkable; the architecture's answer is that a stroke arrives as *parameters* and the device draws it. This is that, from the model through the sidecar to the pixels — but not the finger: the canvas is somebody else's change, and this leaves it a seam rather than reaching into it. **A stroke is a swept disc along a polyline**, plus erase, radius, hardness and flow. `MaskSource::Brush` holds an ordered list of them, and the order is the mask: an erase after an add takes it away and the same pair reversed does not. Nothing about it is pixels, which is what makes a mask that costs a line of text, diffs by the gesture, and survives a crop, a straighten and an export at any size — the properties a stored raster has none of, and the same argument the region ids were chosen for. Two things keep the point count honest. While the finger is down, a position closer to the last than an eighth of the radius is dropped: a touch screen reports 120 a second, so a finger held still for five seconds is six hundred points in the same place, and simplification would only remove them once the gesture had ended — after every frame in between had drawn all of them. When it ends, Douglas–Peucker at an eighth of the radius removes what a disc that wide cannot express: a swept circle moved by r/8 moves its own edge by r/8, which is inside the soft part of any brush. Coordinates snap to a ten-thousandth of the frame on the way in *and* are written at that precision, so a round trip is exact rather than nearly exact — a file that drifts in the sixth decimal every save is a per-field merge conflict a day, over nothing. **Cost is why the strokes are not drawn by the full-screen triangle the other masks use.** A swept disc is the minimum distance to any of its segments, so a stroke over the whole frame costs `pixels × segments` and both terms grow together — the quadratic that is darktable's problem moved onto the GPU rather than solved. Each stroke is instead drawn over its own bounding box, grown by the radius, so the rasteriser never invokes the shader for a pixel the stroke cannot reach: `area(box) × segments`, which for a dab or a swipe is a small fraction of the frame. A gesture past 256 points continues as a second stroke for the same reason, since a shorter stroke has a smaller box. Add and erase are `dst + a(1 - dst)` and `dst(1 - a)`, which are exactly a source-over and a one-minus-source blend — so they are blend state, not arithmetic, and no pass ever reads the slice it is writing. That is what permits one draw per stroke at all. Within a stroke the coverage is the *minimum* distance over its segments rather than a sum: a path that crosses itself must not build up where it did, or every circle and every scribble would be blotchy wherever consecutive dabs overlap, which is everywhere. Not a distance field, deliberately. `dr-segment`'s transform documents the two conditions that make CPU work right there — once per mask edit, over input already CPU-side — and a stroke fails both: it changes while the finger moves, and its input is a handful of coordinates that never needed to be pixels. It also needs no transform, because the distance to a swept disc is closed form. A stroke is the one mask whose distance field is known without computing one. An unpainted brush layer is inactive rather than empty, which is not an optimisation: `invert` turns empty into everything, so a layer created with invert already set would apply its adjustment to the whole photograph before a single stroke was made. That is the loud, confident kind of wrong this codebase refuses everywhere else a mask can go missing, and there is a rendered test for it. The tests read pixels back off a device rather than checking that the two halves agree with each other. What they pin down is what is silent when wrong: the y flip between mask space and clip space, which a centred stroke would not notice; a bounding box not grown by the radius, which makes a tap draw nothing at all; an aspect ratio ignored, which makes a dab an ellipse on any frame that is not square; a stroke doubling back and building up; and an erase that lost its place in the order and put back paint the user had taken off. Not done here: the interaction. The canvas needs to begin, extend and end a stroke on the active layer, and `DevelopSession::rasterise_masks` still returns early without a segmentation — it takes the proxy size from one, and a brush needs no model to have run over the photograph first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1666 lines
64 KiB
Rust
1666 lines
64 KiB
Rust
//! Sidecar serialisation — the edit graph as durable, mergeable data.
|
|
//!
|
|
//! # Generic, for the same reason the UI is generic
|
|
//!
|
|
//! `dr-ui` builds its panel by walking [`EditGraph::capabilities`] and never
|
|
//! names an operation (FR-DEV-3c). This module does the same thing to the
|
|
//! same list: it reads every parameter an operation *declares*, and writes
|
|
//! the ones that differ from their default. No operation implements a
|
|
//! serialisation method, and adding one needs no change here — the
|
|
//! descriptor it already publishes for the UI is exactly the description the
|
|
//! sidecar needs.
|
|
//!
|
|
//! That symmetry is the point. There is one place an operation says what its
|
|
//! parameters are, and both the interface and the persistence layer read it.
|
|
//! A third place would be a third thing to forget to update.
|
|
//!
|
|
//! # Only non-default values are written
|
|
//!
|
|
//! An operation at neutral contributes nothing to the file, which is what
|
|
//! makes the format survive both directions of version skew:
|
|
//!
|
|
//! - **Reading an old sidecar in a new build.** An operation added since is
|
|
//! simply absent, and absence means default, which means neutral. The
|
|
//! image renders as it did.
|
|
//! - **Reading a new sidecar in an old build.** An unknown operation's lines
|
|
//! are preserved verbatim (see [`Version::unknown`]) and written back
|
|
//! untouched, so a device running behind cannot silently destroy an edit it
|
|
//! does not understand.
|
|
//!
|
|
//! The alternative — writing every parameter — would make every file grow
|
|
//! with the operation count and would still not solve either case.
|
|
//!
|
|
//! # Why a flat text format rather than serde
|
|
//!
|
|
//! FR-NC-9 requires conflict merge **at the edit-graph node level**: a crop
|
|
//! made on one device and an exposure change made on another must both
|
|
//! survive. In this format a node *is* a line, keyed by `op.param`, so the
|
|
//! merge is a key-wise comparison over two maps ([`Version::merge`]) rather
|
|
//! than a tree diff. A nested document would need the same map built at merge
|
|
//! time anyway.
|
|
//!
|
|
//! It also keeps this crate dependency-free, which is the property that lets
|
|
//! the descriptor and codegen logic be tested without a device (ARCH §6.5a).
|
|
//!
|
|
//! # Shape
|
|
//!
|
|
//! ```text
|
|
//! drsc 1
|
|
//!
|
|
//! [version 8f04c0e2-1f9a-4a63-b0e9-3d1f5a0c77b1]
|
|
//! name = Default
|
|
//! default = 1
|
|
//! revision = 7
|
|
//! device = 3a1c5f80-9d2e-4b11-8c6a-0f7e2d4b9a35
|
|
//! modified = 1754697600
|
|
//! exposure.exposure = 0.75
|
|
//! framing.crop_w = 0.8
|
|
//! ```
|
|
//!
|
|
//! Values are decimal floats; keys are `op_id.param_id`. Both come from the
|
|
//! descriptors, so the file is readable by a human debugging an edit that
|
|
//! went wrong — which is the case that matters, since sidecars are the
|
|
//! authoritative store (ARCH §6.12) and the catalog is the disposable index.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::fmt;
|
|
use std::fmt::Write as _;
|
|
|
|
use crate::graph::EditGraph;
|
|
use crate::mask::{
|
|
Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER,
|
|
};
|
|
use crate::preset::{resolve, Preset};
|
|
|
|
/// Format version of the document itself.
|
|
///
|
|
/// Bumped only for a change no reader could otherwise survive. Adding an
|
|
/// operation is *not* such a change — that is what the non-default rule
|
|
/// above buys — so this is expected to stay at 1 for a long time.
|
|
pub const FORMAT_VERSION: u32 = 1;
|
|
|
|
/// The file extension for a DarkRoom sidecar.
|
|
pub const EXTENSION: &str = "drsc";
|
|
|
|
/// Highest star rating. Mirrors `dr_catalog::rating::MAX_RATING`; duplicated
|
|
/// rather than shared because this crate deliberately depends on nothing.
|
|
pub const MAX_RATING: u8 = 5;
|
|
|
|
/// Highest flag code: 0 unflagged, 1 pick, 2 reject.
|
|
pub const MAX_FLAG: u8 = 2;
|
|
|
|
/// TRACES: FR-CAT-8 | FR-NC-8
|
|
/// One image's sidecar: a keyed set of versions.
|
|
///
|
|
/// A set rather than a single graph because an image may carry several
|
|
/// virtual copies (FR-CAT-12), and because version identity has to be part of
|
|
/// the format for conflict merge to operate per-version (FR-NC-8).
|
|
#[derive(Debug, Clone, PartialEq, Default)]
|
|
pub struct Sidecar {
|
|
/// Versions by uuid. Ordered, so writing the same state twice produces
|
|
/// byte-identical output — which is what lets a caller skip an upload by
|
|
/// comparing content rather than trusting a dirty flag.
|
|
pub versions: BTreeMap<String, Version>,
|
|
/// Lines from a `[version]` block whose keys this build did not
|
|
/// recognise as `op.param`, and any unrecognised top-level lines.
|
|
///
|
|
/// Preserved so a older build round-trips a newer file without loss.
|
|
unknown_blocks: Vec<String>,
|
|
}
|
|
|
|
/// TRACES: FR-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,
|
|
/// Keys this build did not recognise, kept verbatim.
|
|
///
|
|
/// An operation this build lacks would otherwise be deleted the moment an
|
|
/// older device saved the file — silent data loss across a version skew,
|
|
/// which for an authoritative store is the worst failure available.
|
|
pub unknown: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Version {
|
|
/// A new version holding the non-default parameters of `graph`.
|
|
pub fn from_graph(uuid: impl Into<String>, name: impl Into<String>, graph: &EditGraph) -> Self {
|
|
Self {
|
|
uuid: uuid.into(),
|
|
name: name.into(),
|
|
is_default: false,
|
|
revision: 1,
|
|
device: String::new(),
|
|
modified: 0,
|
|
// A new version is unjudged: the graph says nothing about whether
|
|
// the photograph is any good, and inventing a rating here would
|
|
// put every image at zero stars *deliberately* rather than leaving
|
|
// it in the "not yet looked at" state a cull resumes from.
|
|
rating: 0,
|
|
flag: 0,
|
|
params: capture(graph),
|
|
masks: graph.masks().clone(),
|
|
unknown: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Apply this version's parameters to a graph.
|
|
///
|
|
/// The graph is reset first, so loading is a *replacement* rather than an
|
|
/// overlay: a parameter absent from the file means default, and would
|
|
/// otherwise silently inherit whatever the graph happened to hold.
|
|
///
|
|
/// Unknown operations and parameters are skipped with a warning by
|
|
/// [`EditGraph::set_param`], and values are clamped there, so a corrupt
|
|
/// or newer file cannot reach a shader.
|
|
pub fn apply(&self, graph: &mut EditGraph) {
|
|
graph.reset();
|
|
for ((op, param), value) in &self.params {
|
|
// `OpId` and `ParamId` hold `&'static str` because descriptors
|
|
// are statics, and a sidecar's strings are not. `resolve` matches
|
|
// the file's names against the descriptors and hands back the
|
|
// static ids, so no string read from disk is ever leaked to get
|
|
// a lifetime it did not earn.
|
|
let Some((op, param)) = resolve(graph, op, param) else {
|
|
log::warn!("sidecar: unknown parameter {op}.{param}; ignoring");
|
|
continue;
|
|
};
|
|
graph.set_param(op, param, *value);
|
|
}
|
|
*graph.masks_mut() = self.masks.clone();
|
|
}
|
|
|
|
/// Record `graph` into this version, bumping the revision.
|
|
///
|
|
/// The revision bump is what makes this the write path rather than a
|
|
/// setter: FR-NC-9 resolves conflicts by revision, so a local edit that
|
|
/// did not bump it is a local edit a remote one will silently win.
|
|
pub fn update(&mut self, graph: &EditGraph, device: &str, now: i64) {
|
|
self.params = capture(graph);
|
|
self.masks = graph.masks().clone();
|
|
self.revision = self.revision.saturating_add(1);
|
|
self.device = device.to_string();
|
|
self.modified = now;
|
|
}
|
|
|
|
/// Merge the mask stacks, returning the layers that genuinely conflicted.
|
|
///
|
|
/// Reported as `("mask", id)` so a caller surfacing conflicts can show
|
|
/// them in the same list as contested parameters without needing a second
|
|
/// channel for them.
|
|
fn merge_masks(
|
|
&mut self,
|
|
remote: &Version,
|
|
base: Option<&Version>,
|
|
remote_wins: bool,
|
|
) -> Vec<(String, String)> {
|
|
let empty = MaskStack::new();
|
|
let base_masks = base.map(|b| &b.masks).unwrap_or(&empty);
|
|
let mut conflicts = Vec::new();
|
|
|
|
let ids: Vec<String> = self
|
|
.masks
|
|
.layers()
|
|
.iter()
|
|
.chain(remote.masks.layers())
|
|
.map(|l| l.id.clone())
|
|
.collect::<std::collections::BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect();
|
|
|
|
for id in ids {
|
|
let ours = self.masks.get(&id);
|
|
let theirs = remote.masks.get(&id);
|
|
let was = base_masks.get(&id);
|
|
|
|
let we_changed = ours != was;
|
|
let they_changed = theirs != was;
|
|
|
|
match (we_changed, they_changed) {
|
|
// Only they touched it: take theirs, including a deletion.
|
|
(false, true) => match theirs {
|
|
Some(layer) => self.put_mask(layer.clone()),
|
|
None => {
|
|
self.masks.remove(&id);
|
|
}
|
|
},
|
|
(true, true) if ours != theirs => {
|
|
conflicts.push(("mask".to_string(), id.clone()));
|
|
if remote_wins {
|
|
match theirs {
|
|
Some(layer) => self.put_mask(layer.clone()),
|
|
None => {
|
|
self.masks.remove(&id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
conflicts
|
|
}
|
|
|
|
/// Replace a layer of the same id, or append it.
|
|
///
|
|
/// Position is not merged. Two devices that reordered the same stack have
|
|
/// no combined order that is either one's, and layer order only decides
|
|
/// which of two *overlapping* masks composites last — a much smaller
|
|
/// wrong than losing a layer.
|
|
fn put_mask(&mut self, layer: MaskLayer) {
|
|
match self.masks.get_mut(&layer.id) {
|
|
Some(existing) => *existing = layer,
|
|
None => {
|
|
self.masks.push(layer);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-NC-9
|
|
/// Merge a remote version into this one at the node level.
|
|
///
|
|
/// Disjoint edits both survive: a crop made on one device and an exposure
|
|
/// change made on the other are different keys, so neither is a conflict
|
|
/// and the result carries both. Only a key *both* sides changed is
|
|
/// ambiguous, and those resolve wholesale to the higher revision — not
|
|
/// per key, because two values of the same parameter cannot be combined
|
|
/// into a third that either user intended.
|
|
///
|
|
/// Returns the keys that genuinely conflicted, so a caller can surface
|
|
/// them (FR-NC-9: "only genuinely ambiguous merges surface to the UI").
|
|
pub fn merge(&mut self, remote: &Version, base: Option<&Version>) -> Vec<(String, String)> {
|
|
let empty = BTreeMap::new();
|
|
let base_params = base.map(|b| &b.params).unwrap_or(&empty);
|
|
let changed = |side: &BTreeMap<(String, String), f32>, key: &(String, String)| {
|
|
side.get(key) != base_params.get(key)
|
|
};
|
|
|
|
// The remote wins ties by revision, then by timestamp. Computed once:
|
|
// applying it per key would let a single merge take some keys from
|
|
// each side, producing a state neither device ever had.
|
|
let remote_wins = (remote.revision, remote.modified) > (self.revision, self.modified);
|
|
|
|
let mut conflicts = Vec::new();
|
|
let keys: Vec<(String, String)> = self
|
|
.params
|
|
.keys()
|
|
.chain(remote.params.keys())
|
|
.chain(base_params.keys())
|
|
.cloned()
|
|
.collect::<std::collections::BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect();
|
|
|
|
for key in keys {
|
|
let ours = changed(&self.params, &key);
|
|
let theirs = changed(&remote.params, &key);
|
|
match (ours, theirs) {
|
|
// Only they touched it — take theirs. This is the disjoint
|
|
// case, and the whole reason the merge is key-wise.
|
|
(false, true) => match remote.params.get(&key) {
|
|
Some(v) => {
|
|
self.params.insert(key, *v);
|
|
}
|
|
None => {
|
|
self.params.remove(&key);
|
|
}
|
|
},
|
|
// Both touched it, to different values: genuinely ambiguous.
|
|
(true, true) if self.params.get(&key) != remote.params.get(&key) => {
|
|
conflicts.push(key.clone());
|
|
if remote_wins {
|
|
match remote.params.get(&key) {
|
|
Some(v) => {
|
|
self.params.insert(key, *v);
|
|
}
|
|
None => {
|
|
self.params.remove(&key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Only we touched it, or both landed on the same value.
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Judgement is not a parameter and is not merged key-wise: a rating is
|
|
// a single scalar, so there is no disjoint case to preserve — two
|
|
// devices that both rated a frame simply disagree, and the higher
|
|
// revision is the answer, exactly as for a contested parameter.
|
|
//
|
|
// The asymmetry with `params` is deliberate. A device that has *not*
|
|
// rated a frame holds 0, which is indistinguishable from "rated
|
|
// zero", so treating the remote's 0 as an edit would let an
|
|
// un-culled device silently wipe the ratings of a culled one. Taking
|
|
// a non-zero remote value when we hold none is the safe direction:
|
|
// a judgement can be added across devices but never erased by one
|
|
// that never had it.
|
|
self.rating = merge_judgement(self.rating, remote.rating, remote_wins);
|
|
self.flag = merge_judgement(self.flag, remote.flag, remote_wins);
|
|
|
|
// Masks merge by layer id, which is the same disjoint-survives rule
|
|
// the parameters follow one level up: a layer added on the phone and
|
|
// a layer added on the desktop are different ids, so both survive and
|
|
// neither is a conflict.
|
|
//
|
|
// A layer *both* sides edited resolves wholesale to the higher
|
|
// revision rather than field by field. Two people's versions of one
|
|
// mask cannot be interleaved into a third — half of one selection
|
|
// plus half of another's opacity is a layer neither of them made —
|
|
// so the layer is the unit, exactly as the value is for a parameter.
|
|
conflicts.extend(self.merge_masks(remote, base, remote_wins));
|
|
|
|
// Unknown keys follow the same rule, so an operation neither side
|
|
// understands is not dropped by the merge either.
|
|
for (k, v) in &remote.unknown {
|
|
self.unknown.entry(k.clone()).or_insert_with(|| v.clone());
|
|
}
|
|
|
|
// The merged result is newer than either input, or the next write
|
|
// would look stale to a device that has already seen the remote.
|
|
self.revision = self.revision.max(remote.revision).saturating_add(1);
|
|
self.modified = self.modified.max(remote.modified);
|
|
|
|
conflicts
|
|
}
|
|
}
|
|
|
|
/// Resolve one judgement scalar — a rating or a flag — across two devices.
|
|
///
|
|
/// Zero carries no information here. A device that has never judged a frame
|
|
/// holds 0, and that is indistinguishable from a deliberate "back to
|
|
/// unrated", so the two cases cannot be told apart from the value alone. The
|
|
/// resolution follows from which mistake is worse:
|
|
///
|
|
/// - Taking a remote judgement when we have none **adds** work that was
|
|
/// genuinely done elsewhere. If it was wrong, the user re-presses a key.
|
|
/// - Taking a remote zero when we have a rating **erases** an afternoon of
|
|
/// culling, silently, on a device that was never involved.
|
|
///
|
|
/// So a zero never overwrites a judgement; a real judgement overwrites ours
|
|
/// only when the remote also wins on revision. The cost is that clearing a
|
|
/// rating does not propagate — pressing `0` on one device leaves the other
|
|
/// device's star standing. That is the deliberate trade, and it is the same
|
|
/// direction of caution the merge takes everywhere else.
|
|
fn merge_judgement(ours: u8, theirs: u8, remote_wins: bool) -> u8 {
|
|
match (ours, theirs) {
|
|
// Nothing to lose: any real remote judgement is strictly more
|
|
// information than we hold.
|
|
(0, t) => t,
|
|
// We hold one and they hold none — theirs says nothing.
|
|
(o, 0) => o,
|
|
// Both judged. A genuine disagreement, resolved by revision like any
|
|
// other contested value.
|
|
(o, t) => {
|
|
if remote_wins {
|
|
t
|
|
} else {
|
|
o
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every non-default parameter in the graph, keyed by `(op, param)`.
|
|
///
|
|
/// Reads [`EditGraph::capabilities`] — the same list the UI builds controls
|
|
/// from — so an operation is persisted by virtue of being in the chain, with
|
|
/// nothing to register and nothing to forget.
|
|
///
|
|
/// Delegated to [`Preset::capture`] rather than reimplemented: a version's
|
|
/// parameters and a copied preset are the same values taken from the same
|
|
/// list, and two routines building the same map would be two places for the
|
|
/// non-default rule to drift.
|
|
fn capture(graph: &EditGraph) -> BTreeMap<(String, String), f32> {
|
|
Preset::capture(graph).into_params()
|
|
}
|
|
|
|
impl Sidecar {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// The version marked default, or the first if none is.
|
|
pub fn default_version(&self) -> Option<&Version> {
|
|
self.versions
|
|
.values()
|
|
.find(|v| v.is_default)
|
|
.or_else(|| self.versions.values().next())
|
|
}
|
|
|
|
/// Insert or replace a version.
|
|
pub fn put(&mut self, version: Version) {
|
|
self.versions.insert(version.uuid.clone(), version);
|
|
}
|
|
|
|
/// Serialise to the on-disk form.
|
|
///
|
|
/// Deterministic: the same state always produces the same bytes, so a
|
|
/// caller may compare content to decide whether an upload is needed.
|
|
pub fn to_text(&self) -> String {
|
|
let mut out = format!("drsc {FORMAT_VERSION}\n");
|
|
for block in &self.unknown_blocks {
|
|
let _ = writeln!(out, "{block}");
|
|
}
|
|
for v in self.versions.values() {
|
|
let _ = write!(out, "\n[version {}]\n", v.uuid);
|
|
let _ = writeln!(out, "name = {}", v.name);
|
|
if v.is_default {
|
|
let _ = writeln!(out, "default = 1");
|
|
}
|
|
let _ = writeln!(out, "revision = {}", v.revision);
|
|
if !v.device.is_empty() {
|
|
let _ = writeln!(out, "device = {}", v.device);
|
|
}
|
|
let _ = writeln!(out, "modified = {}", v.modified);
|
|
// Judgement, written only when there is one. An unrated,
|
|
// unflagged frame contributes nothing — the same non-default rule
|
|
// the parameters follow, so a library that has never been culled
|
|
// does not grow a line per file.
|
|
if v.rating > 0 {
|
|
let _ = writeln!(out, "rating = {}", v.rating);
|
|
}
|
|
if v.flag > 0 {
|
|
let _ = writeln!(out, "flag = {}", v.flag);
|
|
}
|
|
for ((op, param), value) in &v.params {
|
|
let _ = writeln!(out, "{op}.{param} = {}", format_value(*value));
|
|
}
|
|
for (k, raw) in &v.unknown {
|
|
let _ = writeln!(out, "{k} = {raw}");
|
|
}
|
|
for layer in v.masks.layers() {
|
|
write_mask(&mut out, &v.uuid, layer);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parse the on-disk form.
|
|
///
|
|
/// Tolerant by design. A sidecar is the authoritative store, so a single
|
|
/// unreadable line must cost that line and not the file: unrecognised
|
|
/// keys are preserved rather than rejected, and a malformed value is
|
|
/// skipped with a warning. The one hard failure is a format version this
|
|
/// build does not understand, where continuing would mean guessing.
|
|
pub fn parse(text: &str) -> Result<Self, ParseError> {
|
|
let mut lines = text.lines();
|
|
let header = lines.next().unwrap_or_default().trim();
|
|
let format = header
|
|
.strip_prefix("drsc ")
|
|
.and_then(|v| v.trim().parse::<u32>().ok())
|
|
.ok_or(ParseError::NotASidecar)?;
|
|
if format > FORMAT_VERSION {
|
|
return Err(ParseError::UnsupportedVersion(format));
|
|
}
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
let mut current: Option<Version> = None;
|
|
// Mask blocks are collected rather than attached as they are read.
|
|
// They name their version explicitly, so they need not follow it in
|
|
// the file — and a `[mask]` block for a version that never appears is
|
|
// then simply dropped instead of corrupting whichever version
|
|
// happened to be open.
|
|
let mut mask: Option<PartialMask> = None;
|
|
let mut masks: Vec<(String, MaskLayer)> = Vec::new();
|
|
|
|
for raw in lines {
|
|
let line = raw.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
if let Some(uuid) = line
|
|
.strip_prefix("[version ")
|
|
.and_then(|s| s.strip_suffix(']'))
|
|
{
|
|
masks.extend(mask.take().and_then(PartialMask::finish));
|
|
if let Some(v) = current.take() {
|
|
sidecar.put(v);
|
|
}
|
|
current = Some(Version {
|
|
uuid: uuid.trim().to_string(),
|
|
..Version::default()
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if let Some(head) = line
|
|
.strip_prefix("[mask ")
|
|
.and_then(|s| s.strip_suffix(']'))
|
|
{
|
|
masks.extend(mask.take().and_then(PartialMask::finish));
|
|
match head.split_once(char::is_whitespace) {
|
|
Some((version, id)) => {
|
|
mask = Some(PartialMask::new(version.trim(), id.trim()));
|
|
}
|
|
// A header missing one of its two names cannot be
|
|
// attached to anything. Dropped with a warning rather
|
|
// than guessed at, since guessing would put someone
|
|
// else's adjustment on this photograph.
|
|
None => log::warn!("sidecar: malformed mask header '{line}'; ignoring"),
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let Some((key, value)) = line.split_once('=') else {
|
|
// Not a key-value line and not a block header. Keep it so a
|
|
// newer format's construct survives a round trip here.
|
|
match &mut current {
|
|
Some(v) => {
|
|
v.unknown.insert(line.to_string(), String::new());
|
|
}
|
|
None => sidecar.unknown_blocks.push(line.to_string()),
|
|
}
|
|
continue;
|
|
};
|
|
let (key, value) = (key.trim(), value.trim());
|
|
|
|
if let Some(m) = mask.as_mut() {
|
|
m.set(key, value);
|
|
continue;
|
|
}
|
|
|
|
let Some(version) = current.as_mut() else {
|
|
sidecar.unknown_blocks.push(line.to_string());
|
|
continue;
|
|
};
|
|
|
|
match key {
|
|
"name" => version.name = value.to_string(),
|
|
"default" => version.is_default = value != "0",
|
|
"revision" => version.revision = value.parse().unwrap_or(0),
|
|
"device" => version.device = value.to_string(),
|
|
"modified" => version.modified = value.parse().unwrap_or(0),
|
|
// Clamped rather than trusted: this file may have been written
|
|
// by a build with a wider scale, or hand-edited. An
|
|
// out-of-range rating would sort above five stars forever and
|
|
// no filter would reach it.
|
|
"rating" => version.rating = value.parse::<u8>().unwrap_or(0).min(MAX_RATING),
|
|
"flag" => version.flag = value.parse::<u8>().unwrap_or(0).min(MAX_FLAG),
|
|
_ => match key.split_once('.') {
|
|
// An `op.param` line whose value does not parse is a
|
|
// corrupt number, not an unknown key; dropping it lets
|
|
// the rest of the edit load, which beats failing the file.
|
|
Some((op, param)) => match value.parse::<f32>() {
|
|
Ok(v) if v.is_finite() => {
|
|
version
|
|
.params
|
|
.insert((op.to_string(), param.to_string()), v);
|
|
}
|
|
_ => log::warn!("sidecar: unreadable value for {key}; ignoring"),
|
|
},
|
|
None => {
|
|
version.unknown.insert(key.to_string(), value.to_string());
|
|
}
|
|
},
|
|
}
|
|
}
|
|
masks.extend(mask.take().and_then(PartialMask::finish));
|
|
if let Some(v) = current.take() {
|
|
sidecar.put(v);
|
|
}
|
|
|
|
for (uuid, layer) in masks {
|
|
match sidecar.versions.get_mut(&uuid) {
|
|
Some(v) => {
|
|
v.masks.push(layer);
|
|
}
|
|
None => log::warn!(
|
|
"sidecar: mask {} names version {uuid}, which is not in this file; ignoring",
|
|
layer.id
|
|
),
|
|
}
|
|
}
|
|
|
|
Ok(sidecar)
|
|
}
|
|
}
|
|
|
|
|
|
/// Write one mask layer as its own block.
|
|
///
|
|
/// The version uuid is repeated in the header rather than relying on the
|
|
/// block's position in the file. A sidecar is edited by hand, merged by two
|
|
/// devices, and round-tripped by builds that do not know what a mask is —
|
|
/// under all three, "belongs to whichever version appeared above me" is a
|
|
/// relationship that quietly breaks. Naming it costs one field.
|
|
fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
|
|
let _ = write!(out, "\n[mask {version} {}]\n", layer.id);
|
|
if !layer.name.is_empty() {
|
|
let _ = writeln!(out, "name = {}", layer.name);
|
|
}
|
|
let _ = writeln!(out, "source = {}", layer.source.kind());
|
|
|
|
match &layer.source {
|
|
MaskSource::Regions {
|
|
signature,
|
|
level,
|
|
ids,
|
|
} => {
|
|
let _ = writeln!(out, "signature = {signature}");
|
|
let _ = writeln!(out, "level = {level}");
|
|
// One space-separated line rather than a key per id: a selection
|
|
// is a few hundred numbers, and three hundred lines of
|
|
// `region.7 = 1` would bury the rest of the file.
|
|
let list: Vec<String> = ids.iter().map(|i| i.to_string()).collect();
|
|
let _ = writeln!(out, "regions = {}", list.join(" "));
|
|
}
|
|
MaskSource::Subject {
|
|
signature,
|
|
index,
|
|
class,
|
|
score,
|
|
} => {
|
|
let _ = writeln!(out, "signature = {signature}");
|
|
let _ = writeln!(out, "index = {index}");
|
|
let _ = writeln!(out, "class = {class}");
|
|
let _ = writeln!(out, "score = {}", format_value(*score));
|
|
}
|
|
MaskSource::Linear {
|
|
centre,
|
|
angle,
|
|
width,
|
|
} => {
|
|
let _ = writeln!(
|
|
out,
|
|
"centre = {} {}",
|
|
format_value(centre.0),
|
|
format_value(centre.1)
|
|
);
|
|
let _ = writeln!(out, "angle = {}", format_value(*angle));
|
|
let _ = writeln!(out, "width = {}", format_value(*width));
|
|
}
|
|
MaskSource::Radial {
|
|
centre,
|
|
radii,
|
|
angle,
|
|
feather,
|
|
} => {
|
|
let _ = writeln!(
|
|
out,
|
|
"centre = {} {}",
|
|
format_value(centre.0),
|
|
format_value(centre.1)
|
|
);
|
|
let _ = writeln!(
|
|
out,
|
|
"radii = {} {}",
|
|
format_value(radii.0),
|
|
format_value(radii.1)
|
|
);
|
|
let _ = writeln!(out, "angle = {}", format_value(*angle));
|
|
let _ = writeln!(out, "feather = {}", format_value(*feather));
|
|
}
|
|
MaskSource::Brush { strokes } => write_strokes(out, strokes),
|
|
}
|
|
|
|
if layer.invert {
|
|
let _ = writeln!(out, "invert = 1");
|
|
}
|
|
if layer.opacity != 1.0 {
|
|
let _ = writeln!(out, "opacity = {}", format_value(layer.opacity));
|
|
}
|
|
if !layer.enabled {
|
|
let _ = writeln!(out, "enabled = 0");
|
|
}
|
|
// The edge treatment, written only when it is not the default — the same
|
|
// rule the parameters follow, so a file stays readable and a mask nobody
|
|
// has fiddled with contributes four fewer lines.
|
|
if layer.feather != DEFAULT_FEATHER {
|
|
let _ = writeln!(out, "edge-feather = {}", format_value(layer.feather));
|
|
}
|
|
if layer.falloff != Falloff::default() {
|
|
let _ = writeln!(out, "edge-falloff = {}", layer.falloff.name());
|
|
}
|
|
if layer.morphology != Morphology::default() {
|
|
let _ = writeln!(out, "morphology = {}", layer.morphology.name());
|
|
let _ = writeln!(out, "morph-radius = {}", format_value(layer.morph_radius));
|
|
}
|
|
for (op, param, value) in layer.params() {
|
|
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
|
|
}
|
|
}
|
|
|
|
/// Write a brush layer's strokes, one line each.
|
|
///
|
|
/// A line per stroke, in the order they were painted, because the order *is*
|
|
/// the mask: an erase after an add removes it and the same pair reversed does
|
|
/// not. It is also the granularity anyone reading a diff wants — a stroke is
|
|
/// what the user made and what an undo takes back. A line per point would bury
|
|
/// the rest of the file, and one line for the whole layer would make adding a
|
|
/// stroke look like the entire mask had been rewritten.
|
|
///
|
|
/// Points are `x,y` pairs rather than a flat run of numbers. A truncated or
|
|
/// hand-edited line would otherwise shift every coordinate by one and land the
|
|
/// mask somewhere else entirely, which is the failure that looks like the
|
|
/// software forgot the edit rather than like a damaged file.
|
|
fn write_strokes(out: &mut String, strokes: &[Stroke]) {
|
|
for stroke in strokes {
|
|
let _ = write!(
|
|
out,
|
|
"stroke = {} {} {} {}",
|
|
if stroke.erase { "erase" } else { "add" },
|
|
format_value(stroke.radius),
|
|
format_value(stroke.hardness),
|
|
format_value(stroke.flow),
|
|
);
|
|
for (x, y) in &stroke.points {
|
|
let _ = write!(out, " {},{}", format_value(*x), format_value(*y));
|
|
}
|
|
let _ = writeln!(out);
|
|
}
|
|
}
|
|
|
|
/// Read one `stroke = …` line, or nothing if it cannot be trusted.
|
|
///
|
|
/// A malformed stroke costs that stroke and not the layer. Refusing the whole
|
|
/// block would throw away every other stroke on it over one bad line, and
|
|
/// guessing at the missing half would put paint somewhere the user never
|
|
/// touched — which of the three is worst depends on the line, but a wrong mask
|
|
/// is the only one that looks like it worked.
|
|
fn parse_stroke(value: &str) -> Option<Stroke> {
|
|
let mut tokens = value.split_whitespace();
|
|
|
|
let erase = match tokens.next()? {
|
|
"add" => false,
|
|
"erase" => true,
|
|
other => {
|
|
log::warn!("sidecar: stroke is neither add nor erase ('{other}'); ignoring it");
|
|
return None;
|
|
}
|
|
};
|
|
let radius: f32 = tokens.next()?.parse().ok()?;
|
|
let hardness: f32 = tokens.next()?.parse().ok()?;
|
|
let flow: f32 = tokens.next()?.parse().ok()?;
|
|
if !(radius.is_finite() && hardness.is_finite() && flow.is_finite()) {
|
|
return None;
|
|
}
|
|
|
|
let mut stroke = Stroke::new(erase, radius, hardness, flow);
|
|
for token in tokens {
|
|
let (x, y) = token.split_once(',')?;
|
|
let (x, y) = (x.parse::<f32>().ok()?, y.parse::<f32>().ok()?);
|
|
if !(x.is_finite() && y.is_finite()) {
|
|
return None;
|
|
}
|
|
// Straight onto the list rather than through `push_point`, which drops
|
|
// a point too close to the last: that rule belongs to a finger being
|
|
// dragged, and applying it here would quietly rewrite a stroke every
|
|
// time the file was read — so a sidecar would not survive its own round
|
|
// trip, and two devices would rewrite each other's masks forever.
|
|
stroke.points.push((x, y));
|
|
}
|
|
|
|
(!stroke.is_empty()).then_some(stroke)
|
|
}
|
|
|
|
/// A mask block being read, before it is complete enough to be a layer.
|
|
///
|
|
/// Separate from [`MaskLayer`] because the source cannot be built until every
|
|
/// one of its fields has been seen, and the fields arrive one line at a time
|
|
/// in whatever order the writer chose.
|
|
struct PartialMask {
|
|
version: String,
|
|
id: String,
|
|
name: String,
|
|
kind: String,
|
|
signature: u64,
|
|
level: u32,
|
|
ids: Vec<u32>,
|
|
index: u32,
|
|
class: String,
|
|
score: f32,
|
|
centre: (f32, f32),
|
|
radii: (f32, f32),
|
|
angle: f32,
|
|
width: f32,
|
|
feather: f32,
|
|
invert: bool,
|
|
opacity: f32,
|
|
enabled: bool,
|
|
/// The *layer's* edge transition, distinct from the radial source's own
|
|
/// `feather` above — different quantity, different units, different key.
|
|
edge_feather: f32,
|
|
falloff: Falloff,
|
|
morphology: Morphology,
|
|
morph_radius: f32,
|
|
strokes: Vec<Stroke>,
|
|
params: Vec<(String, String, f32)>,
|
|
}
|
|
|
|
impl PartialMask {
|
|
fn new(version: &str, id: &str) -> Self {
|
|
Self {
|
|
version: version.to_string(),
|
|
id: id.to_string(),
|
|
name: String::new(),
|
|
kind: String::new(),
|
|
signature: 0,
|
|
level: 0,
|
|
ids: Vec::new(),
|
|
index: 0,
|
|
class: String::new(),
|
|
score: 0.0,
|
|
centre: (0.5, 0.5),
|
|
radii: (0.25, 0.25),
|
|
angle: 0.0,
|
|
width: 0.0,
|
|
feather: 0.0,
|
|
invert: false,
|
|
opacity: 1.0,
|
|
enabled: true,
|
|
edge_feather: DEFAULT_FEATHER,
|
|
falloff: Falloff::default(),
|
|
morphology: Morphology::default(),
|
|
morph_radius: 0.0,
|
|
strokes: Vec::new(),
|
|
params: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn set(&mut self, key: &str, value: &str) {
|
|
match key {
|
|
"name" => self.name = value.to_string(),
|
|
"source" => self.kind = value.to_string(),
|
|
"signature" => self.signature = value.parse().unwrap_or(0),
|
|
"level" => self.level = value.parse().unwrap_or(0),
|
|
"regions" => {
|
|
self.ids = value
|
|
.split_whitespace()
|
|
.filter_map(|t| t.parse().ok())
|
|
.collect();
|
|
// Sorted and deduplicated on the way in rather than trusted
|
|
// from the file: the mask's identity is the *set*, and a
|
|
// hand-edited or merged line arriving out of order would
|
|
// otherwise be a different cache key for the same selection.
|
|
self.ids.sort_unstable();
|
|
self.ids.dedup();
|
|
}
|
|
"index" => self.index = value.parse().unwrap_or(0),
|
|
"class" => self.class = value.to_string(),
|
|
"score" => self.score = value.parse().unwrap_or(0.0),
|
|
"centre" => self.centre = pair(value).unwrap_or(self.centre),
|
|
"radii" => self.radii = pair(value).unwrap_or(self.radii),
|
|
"angle" => self.angle = value.parse().unwrap_or(0.0),
|
|
"width" => self.width = value.parse().unwrap_or(0.0),
|
|
"feather" => self.feather = value.parse().unwrap_or(0.0),
|
|
// Appended rather than assigned: a brush layer is a list of these,
|
|
// and the file's line order is the order they were painted in.
|
|
"stroke" => self.strokes.extend(parse_stroke(value)),
|
|
"invert" => self.invert = value != "0",
|
|
"opacity" => self.opacity = value.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0),
|
|
"enabled" => self.enabled = value != "0",
|
|
// Clamped, not trusted: a feather wider than the frame is not a
|
|
// mask, and a negative one is a distance field read backwards.
|
|
"edge-feather" => {
|
|
self.edge_feather = value
|
|
.parse::<f32>()
|
|
.unwrap_or(DEFAULT_FEATHER)
|
|
.clamp(0.0, 1.0)
|
|
}
|
|
"morph-radius" => {
|
|
self.morph_radius = value.parse::<f32>().unwrap_or(0.0).clamp(0.0, 1.0)
|
|
}
|
|
// An unrecognised name falls back to the default rather than
|
|
// dropping the layer. A newer build's falloff curve is a cosmetic
|
|
// difference in the edge; losing the selection under it would not
|
|
// be cosmetic.
|
|
"edge-falloff" => {
|
|
self.falloff = Falloff::from_name(value).unwrap_or_else(|| {
|
|
log::warn!("sidecar: unknown falloff '{value}'; using the default");
|
|
Falloff::default()
|
|
})
|
|
}
|
|
"morphology" => {
|
|
self.morphology = Morphology::from_name(value).unwrap_or_else(|| {
|
|
log::warn!("sidecar: unknown morphology '{value}'; using none");
|
|
Morphology::default()
|
|
})
|
|
}
|
|
_ => match (key.split_once('.'), value.parse::<f32>()) {
|
|
(Some((op, param)), Ok(v)) if v.is_finite() => {
|
|
self.params.push((op.to_string(), param.to_string(), v));
|
|
}
|
|
_ => log::warn!("sidecar: unreadable mask key {key}; ignoring"),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Build the layer, or `None` if the source kind is one this build has
|
|
/// never heard of — a newer format's mask type, which is skipped rather
|
|
/// than guessed at.
|
|
fn finish(self) -> Option<(String, MaskLayer)> {
|
|
let source = match self.kind.as_str() {
|
|
"regions" => MaskSource::Regions {
|
|
signature: self.signature,
|
|
level: self.level,
|
|
ids: self.ids,
|
|
},
|
|
"subject" => MaskSource::Subject {
|
|
signature: self.signature,
|
|
index: self.index,
|
|
class: self.class,
|
|
score: self.score,
|
|
},
|
|
"linear" => MaskSource::Linear {
|
|
centre: self.centre,
|
|
angle: self.angle,
|
|
width: self.width,
|
|
},
|
|
"radial" => MaskSource::Radial {
|
|
centre: self.centre,
|
|
radii: self.radii,
|
|
angle: self.angle,
|
|
feather: self.feather,
|
|
},
|
|
"brush" => MaskSource::Brush {
|
|
strokes: self.strokes,
|
|
},
|
|
other => {
|
|
log::warn!(
|
|
"sidecar: unknown mask source '{other}'; skipping layer {}",
|
|
self.id
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let mut layer = MaskLayer::new(self.id, source);
|
|
layer.name = self.name;
|
|
layer.invert = self.invert;
|
|
layer.opacity = self.opacity;
|
|
layer.enabled = self.enabled;
|
|
layer.feather = self.edge_feather;
|
|
layer.falloff = self.falloff;
|
|
layer.morphology = self.morphology;
|
|
layer.morph_radius = self.morph_radius;
|
|
for (op, param, value) in &self.params {
|
|
// `ParamId` holds a `&'static str` and this one came off disk, so
|
|
// it is matched against the descriptors and the *static* id is
|
|
// what reaches the operation — exactly what `resolve` does for the
|
|
// global chain.
|
|
let Some(id) = layer
|
|
.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id.0 == op)
|
|
.and_then(|o| o.descriptor().params.iter().find(|p| p.id.0 == param))
|
|
.map(|p| p.id)
|
|
else {
|
|
log::warn!("sidecar: unknown mask parameter {op}.{param}; ignoring");
|
|
continue;
|
|
};
|
|
layer.set_param(op, id, *value);
|
|
}
|
|
|
|
Some((self.version, layer))
|
|
}
|
|
}
|
|
|
|
/// Two whitespace-separated floats.
|
|
fn pair(value: &str) -> Option<(f32, f32)> {
|
|
let mut it = value.split_whitespace();
|
|
let a = it.next()?.parse().ok()?;
|
|
let b = it.next()?.parse().ok()?;
|
|
Some((a, b))
|
|
}
|
|
|
|
/// Format a value without a trailing `.0` on whole numbers, and without
|
|
/// exponent notation — both so the file stays diffable and hand-readable.
|
|
fn format_value(v: f32) -> String {
|
|
let mut s = format!("{v:.6}");
|
|
if s.contains('.') {
|
|
s = s.trim_end_matches('0').trim_end_matches('.').to_string();
|
|
}
|
|
if s == "-0" {
|
|
s = "0".to_string();
|
|
}
|
|
s
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum ParseError {
|
|
/// The header line was missing or not a `drsc` header.
|
|
NotASidecar,
|
|
/// Written by a newer build, in a format this one cannot read.
|
|
UnsupportedVersion(u32),
|
|
}
|
|
|
|
impl fmt::Display for ParseError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::NotASidecar => f.write_str("not a DarkRoom sidecar"),
|
|
Self::UnsupportedVersion(v) => {
|
|
write!(f, "sidecar format version {v} is newer than this build")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for ParseError {}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::framing;
|
|
use crate::ops::{exposure, saturation, white_balance};
|
|
|
|
fn edited() -> EditGraph {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
|
|
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
|
|
g
|
|
}
|
|
|
|
fn version_of(graph: &EditGraph) -> Version {
|
|
Version::from_graph("uuid-1", "Default", graph)
|
|
}
|
|
|
|
#[test]
|
|
fn only_non_default_values_are_written() {
|
|
// The property the whole format rests on: a neutral operation is
|
|
// absent, so a file stays small and an operation added later reads
|
|
// as neutral rather than as missing.
|
|
let v = version_of(&edited());
|
|
assert_eq!(v.params.len(), 2);
|
|
assert!(v
|
|
.params
|
|
.contains_key(&("exposure".into(), "exposure".into())));
|
|
assert!(!v.params.keys().any(|(op, _)| op == saturation::ID.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_neutral_graph_writes_no_parameters() {
|
|
let v = version_of(&EditGraph::default_chain());
|
|
assert!(v.params.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_graph_round_trips_through_text() {
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&edited()));
|
|
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored);
|
|
|
|
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
|
assert_eq!(
|
|
restored.param(white_balance::ID, white_balance::TEMPERATURE),
|
|
Some(30.0)
|
|
);
|
|
assert!(!restored.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn every_parameter_in_the_chain_round_trips() {
|
|
// The generic claim, asserted against the whole chain rather than a
|
|
// sample: if an operation needed special handling to persist, this
|
|
// is where it would fail.
|
|
let mut g = EditGraph::default_chain();
|
|
for cap in g.capabilities() {
|
|
for p in &cap.params {
|
|
if let crate::ParamKind::Scalar { max, precision, .. } = p.kind {
|
|
let step = 10f32.powi(i32::from(precision));
|
|
let target = (max * 0.5 * step).round() / step;
|
|
g.set_param(cap.id, p.id, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&g));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored);
|
|
|
|
for cap in g.capabilities() {
|
|
for p in &cap.params {
|
|
assert_eq!(
|
|
restored.param(cap.id, p.id),
|
|
Some(p.value),
|
|
"{}.{} did not survive the sidecar",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn framing_survives_the_round_trip() {
|
|
// Framing is not an `Operation`, so it is exactly the stage a
|
|
// serialiser written against the op list alone would silently drop.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_crop(crate::CropRect {
|
|
x: 0.1,
|
|
y: 0.2,
|
|
width: 0.5,
|
|
height: 0.6,
|
|
});
|
|
g.set_param(framing::ID, framing::ANGLE, -1.5);
|
|
g.rotate_quarters(1);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&g));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored);
|
|
|
|
assert_eq!(restored.crop(), g.crop());
|
|
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
|
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_sidecar_neither_records_nor_erases_the_files_orientation() {
|
|
// How a file stored its pixels is a fact about the file, so it must
|
|
// not travel in the sidecar — a shared edit would then carry one
|
|
// camera's sensor scan onto another's. Two failures are checked
|
|
// together because they are the same mistake seen from each end.
|
|
let mut sideways = EditGraph::default_chain();
|
|
sideways.set_orientation(dr_types::Orientation::from_exif(6));
|
|
|
|
// Nothing was edited, so there is nothing to write. If the baseline
|
|
// leaked into `param`, a rotation would appear here.
|
|
let v = version_of(&sideways);
|
|
let text = {
|
|
let mut s = Sidecar::new();
|
|
s.put(v);
|
|
s.to_text()
|
|
};
|
|
assert!(
|
|
!text.contains("framing.rotation"),
|
|
"an untouched sideways file wrote a rotation:\n{text}"
|
|
);
|
|
|
|
// And applying an edit — which resets the graph first — must leave the
|
|
// orientation where it was, or reopening an edited portrait frame
|
|
// shows it on its side.
|
|
let mut edited = EditGraph::default_chain();
|
|
edited.set_param(framing::ID, framing::ANGLE, -1.5);
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&edited));
|
|
Sidecar::parse(&sidecar.to_text())
|
|
.expect("valid")
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut sideways);
|
|
|
|
assert_eq!(
|
|
sideways.framing().baseline(),
|
|
dr_types::Orientation::from_exif(6)
|
|
);
|
|
assert_eq!(sideways.output_size(6000, 4000), (4000, 6000));
|
|
}
|
|
|
|
#[test]
|
|
fn applying_a_version_replaces_rather_than_overlays() {
|
|
// Loading an edit onto a graph that already holds one must not leave
|
|
// the previous image's exposure behind.
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&EditGraph::default_chain()));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut g = edited();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
assert!(g.is_neutral(), "a neutral version must clear the graph");
|
|
}
|
|
|
|
#[test]
|
|
fn writing_the_same_state_twice_is_byte_identical() {
|
|
// What lets a caller skip an upload by comparing content.
|
|
let mut a = Sidecar::new();
|
|
a.put(version_of(&edited()));
|
|
let once = a.to_text();
|
|
let twice = Sidecar::parse(&once).expect("valid").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_operation_survives_a_round_trip() {
|
|
// The data-loss case that matters: a device running an older build
|
|
// opens a file written by a newer one, saves, and must not delete
|
|
// the operation it never understood.
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 3\nmodified = 5\n\
|
|
exposure.exposure = 0.5\ntime_machine.year = 1994\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let written = parsed.to_text();
|
|
assert!(
|
|
written.contains("time_machine.year = 1994"),
|
|
"an unknown operation must not be dropped:\n{written}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_operation_does_not_reach_the_graph() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
time_machine.year = 1994\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
assert!(g.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_value_costs_its_line_and_not_the_file() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
exposure.exposure = NaN\nwhite_balance.temperature = 20\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
|
assert_eq!(
|
|
g.param(white_balance::ID, white_balance::TEMPERATURE),
|
|
Some(20.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
|
|
// A sidecar written by a build with a wider range must not put an
|
|
// out-of-range value into a uniform.
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
exposure.exposure = 99\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_newer_format_version_is_refused_rather_than_guessed() {
|
|
let err = Sidecar::parse("drsc 99\n").unwrap_err();
|
|
assert_eq!(err, ParseError::UnsupportedVersion(99));
|
|
}
|
|
|
|
#[test]
|
|
fn a_non_sidecar_is_rejected() {
|
|
assert_eq!(
|
|
Sidecar::parse("<?xml version=\"1.0\"?>").unwrap_err(),
|
|
ParseError::NotASidecar
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn several_versions_are_kept_apart() {
|
|
// FR-CAT-12: one image, several virtual copies, independent edits.
|
|
let mut sidecar = Sidecar::new();
|
|
let mut a = Version::from_graph("u1", "Colour", &edited());
|
|
a.is_default = true;
|
|
let mut mono = EditGraph::default_chain();
|
|
mono.set_param(saturation::ID, saturation::SATURATION, -100.0);
|
|
sidecar.put(a);
|
|
sidecar.put(Version::from_graph("u2", "Mono", &mono));
|
|
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
assert_eq!(parsed.versions.len(), 2);
|
|
assert_eq!(parsed.default_version().expect("default").name, "Colour");
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.versions["u2"].apply(&mut g);
|
|
assert_eq!(
|
|
g.param(saturation::ID, saturation::SATURATION),
|
|
Some(-100.0)
|
|
);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn update_bumps_the_revision() {
|
|
// FR-NC-9 resolves by revision; a write that did not bump it would
|
|
// lose to a stale remote.
|
|
let mut v = version_of(&EditGraph::default_chain());
|
|
let before = v.revision;
|
|
v.update(&edited(), "device-a", 1000);
|
|
assert_eq!(v.revision, before + 1);
|
|
assert_eq!(v.device, "device-a");
|
|
assert_eq!(v.modified, 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn disjoint_edits_both_survive_a_merge() {
|
|
// FR-NC-9's motivating case, verbatim: a crop on one device and an
|
|
// exposure change on the other.
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
let mut lg = EditGraph::default_chain();
|
|
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
local.update(&lg, "device-a", 100);
|
|
|
|
let mut remote = base.clone();
|
|
let mut rg = EditGraph::default_chain();
|
|
rg.set_crop(crate::CropRect {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
remote.update(&rg, "device-b", 200);
|
|
|
|
let conflicts = local.merge(&remote, Some(&base));
|
|
assert!(conflicts.is_empty(), "disjoint edits must not conflict");
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
|
|
assert_eq!(g.crop().width, 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn a_genuine_conflict_resolves_to_the_higher_revision() {
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
let mut lg = EditGraph::default_chain();
|
|
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
local.update(&lg, "device-a", 100);
|
|
|
|
let mut remote = base.clone();
|
|
let mut rg = EditGraph::default_chain();
|
|
rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
remote.update(&rg, "device-b", 200);
|
|
remote.revision = local.revision + 1;
|
|
|
|
let conflicts = local.merge(&remote, Some(&base));
|
|
assert_eq!(conflicts.len(), 1, "the same parameter, two values");
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_skewed_clock_cannot_beat_a_higher_revision() {
|
|
// Revision ahead of timestamp, deliberately: a device with a wrong
|
|
// clock must not silently overwrite real work.
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
let mut lg = EditGraph::default_chain();
|
|
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
local.update(&lg, "device-a", 100);
|
|
local.revision = 50;
|
|
|
|
let mut remote = base.clone();
|
|
let mut rg = EditGraph::default_chain();
|
|
rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
remote.update(&rg, "device-b", 999_999);
|
|
remote.revision = 2;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert_eq!(
|
|
g.param(exposure::ID, exposure::EXPOSURE),
|
|
Some(1.0),
|
|
"the far-future timestamp must not win against a higher revision"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_remote_reset_to_default_removes_the_parameter() {
|
|
// Deletion is an edit too: clearing exposure on another device must
|
|
// propagate, not be masked by the key simply being absent.
|
|
let mut base = version_of(&edited());
|
|
base.revision = 1;
|
|
|
|
let mut local = base.clone();
|
|
let mut remote = base.clone();
|
|
remote.update(&EditGraph::default_chain(), "device-b", 200);
|
|
|
|
local.merge(&remote, Some(&base));
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert!(g.is_neutral(), "the remote reset must survive the merge");
|
|
}
|
|
|
|
#[test]
|
|
fn a_merge_is_newer_than_either_input() {
|
|
let base = version_of(&EditGraph::default_chain());
|
|
let mut local = base.clone();
|
|
local.revision = 4;
|
|
let mut remote = base.clone();
|
|
remote.revision = 9;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
assert!(local.revision > 9, "a merged result must not look stale");
|
|
}
|
|
|
|
#[test]
|
|
fn a_rating_survives_the_round_trip() {
|
|
// The durability requirement: the catalog is disposable (ARCH §6.12),
|
|
// so a cull that lives only there is a cull one `rm` destroys.
|
|
let mut v = version_of(&EditGraph::default_chain());
|
|
v.rating = 4;
|
|
v.flag = 1;
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(v);
|
|
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
let back = parsed.default_version().expect("a version");
|
|
assert_eq!(back.rating, 4);
|
|
assert_eq!(back.flag, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unrated_image_writes_no_judgement_lines() {
|
|
// The non-default rule applied to judgement: a library that has never
|
|
// been culled must not grow two lines per file.
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&EditGraph::default_chain()));
|
|
|
|
let text = sidecar.to_text();
|
|
assert!(!text.contains("rating"), "{text}");
|
|
assert!(!text.contains("flag"), "{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn judgement_travels_with_an_otherwise_neutral_edit() {
|
|
// Culling produces no pixel change at all, so this is the *normal*
|
|
// sidecar during a cull — not an edge case. A writer that skipped
|
|
// files with a neutral graph would drop every rating.
|
|
let mut v = version_of(&EditGraph::default_chain());
|
|
v.rating = 5;
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(v);
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let back = parsed.default_version().expect("a version");
|
|
assert_eq!(back.rating, 5);
|
|
assert!(back.params.is_empty(), "no edit, just a judgement");
|
|
}
|
|
|
|
#[test]
|
|
fn an_out_of_range_rating_in_a_file_is_clamped() {
|
|
// Written by a build with a wider scale, or hand-edited. A stored 9
|
|
// would sort above five stars and no filter would reach it.
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
rating = 9\nflag = 77\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let v = parsed.default_version().expect("a version");
|
|
assert_eq!(v.rating, MAX_RATING);
|
|
assert_eq!(v.flag, MAX_FLAG);
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_rating_costs_its_line_and_not_the_file() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
rating = later\nexposure.exposure = 0.5\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let v = parsed.default_version().expect("a version");
|
|
assert_eq!(v.rating, 0);
|
|
assert_eq!(
|
|
v.params.get(&("exposure".into(), "exposure".into())),
|
|
Some(&0.5),
|
|
"the rest of the edit still loads"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_rating_from_a_device_that_never_culled_does_not_erase_one() {
|
|
// The failure this merge rule exists to prevent: a tablet that synced
|
|
// before the cull holds 0, and must not wipe the desktop's afternoon
|
|
// of work merely by having a later revision.
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
local.rating = 5;
|
|
local.revision = 2;
|
|
|
|
let mut remote = base.clone();
|
|
remote.rating = 0;
|
|
remote.revision = 99;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
assert_eq!(local.rating, 5, "an unjudged remote must not erase a cull");
|
|
}
|
|
|
|
#[test]
|
|
fn a_rating_made_elsewhere_arrives_when_we_have_none() {
|
|
// The other direction: culling on the tablet must reach the desktop.
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
let mut remote = base.clone();
|
|
remote.rating = 3;
|
|
remote.flag = 1;
|
|
remote.revision = 5;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
assert_eq!(local.rating, 3);
|
|
assert_eq!(local.flag, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn two_devices_that_both_rated_resolve_by_revision() {
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
local.rating = 2;
|
|
local.revision = 3;
|
|
|
|
let mut remote = base.clone();
|
|
remote.rating = 5;
|
|
remote.revision = 9;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
assert_eq!(local.rating, 5, "the higher revision wins a real conflict");
|
|
}
|
|
|
|
#[test]
|
|
fn a_lower_revision_does_not_overwrite_our_rating() {
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
local.rating = 5;
|
|
local.revision = 40;
|
|
|
|
let mut remote = base.clone();
|
|
remote.rating = 1;
|
|
remote.revision = 2;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
assert_eq!(local.rating, 5);
|
|
}
|
|
|
|
#[test]
|
|
fn a_rating_and_an_edit_merge_independently() {
|
|
// Culling on a tablet while editing on a desktop is the whole point of
|
|
// the multi-device workflow (FR-CULL-7); neither may cost the other.
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
let mut lg = EditGraph::default_chain();
|
|
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
|
local.update(&lg, "desktop", 100);
|
|
|
|
let mut remote = base.clone();
|
|
remote.rating = 4;
|
|
remote.revision = local.revision + 1;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
|
|
assert_eq!(local.rating, 4, "the tablet's cull arrived");
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert_eq!(
|
|
g.param(exposure::ID, exposure::EXPOSURE),
|
|
Some(1.5),
|
|
"and the desktop's edit survived it"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn values_are_written_without_exponent_notation() {
|
|
// The file is meant to be readable when an edit goes wrong, and a
|
|
// sidecar is the authoritative store — so that case matters.
|
|
assert_eq!(format_value(0.0001), "0.0001");
|
|
assert_eq!(format_value(1.0), "1");
|
|
assert_eq!(format_value(-0.0), "0");
|
|
assert_eq!(format_value(0.75), "0.75");
|
|
}
|
|
}
|