1750 lines
68 KiB
Rust
1750 lines
68 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::{curve, exposure, saturation, white_balance};
|
|
|
|
fn edited() -> EditGraph {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.75);
|
|
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 30.0);
|
|
g
|
|
}
|
|
|
|
fn version_of(graph: &EditGraph) -> Version {
|
|
Version::from_graph("uuid-1", "Default", graph)
|
|
}
|
|
|
|
#[test]
|
|
fn only_non_default_values_are_written() {
|
|
// The property the whole format rests on: a neutral operation is
|
|
// absent, so a file stays small and an operation added later reads
|
|
// as neutral rather than as missing.
|
|
let v = version_of(&edited());
|
|
assert_eq!(v.params.len(), 2);
|
|
assert!(v
|
|
.params
|
|
.contains_key(&("exposure".into(), "exposure".into())));
|
|
assert!(!v.params.keys().any(|(op, _)| op == saturation::ID.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_neutral_graph_writes_no_parameters() {
|
|
let v = version_of(&EditGraph::default_chain());
|
|
assert!(v.params.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_graph_round_trips_through_text() {
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&edited()));
|
|
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored);
|
|
|
|
assert_eq!(restored.param(exposure::ID, exposure::EXPOSURE), Some(0.75));
|
|
assert_eq!(
|
|
restored.param(white_balance::ID, white_balance::TEMPERATURE),
|
|
Some(30.0)
|
|
);
|
|
assert!(!restored.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn every_parameter_in_the_chain_round_trips() {
|
|
// The generic claim, asserted against the whole chain rather than a
|
|
// sample: if an operation needed special handling to persist, this
|
|
// is where it would fail.
|
|
let mut g = EditGraph::default_chain();
|
|
for cap in g.capabilities() {
|
|
for p in &cap.params {
|
|
if let crate::ParamKind::Scalar { max, precision, .. } = p.kind {
|
|
let step = 10f32.powi(i32::from(precision));
|
|
let target = (max * 0.5 * step).round() / step;
|
|
g.set_param(cap.id, p.id, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&g));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored);
|
|
|
|
for cap in g.capabilities() {
|
|
for p in &cap.params {
|
|
assert_eq!(
|
|
restored.param(cap.id, p.id),
|
|
Some(p.value),
|
|
"{}.{} did not survive the sidecar",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn framing_survives_the_round_trip() {
|
|
// Framing is not an `Operation`, so it is exactly the stage a
|
|
// serialiser written against the op list alone would silently drop.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_crop(crate::CropRect {
|
|
x: 0.1,
|
|
y: 0.2,
|
|
width: 0.5,
|
|
height: 0.6,
|
|
});
|
|
g.set_param(framing::ID, framing::ANGLE, -1.5);
|
|
g.rotate_quarters(1);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&g));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored);
|
|
|
|
assert_eq!(restored.crop(), g.crop());
|
|
assert_eq!(restored.param(framing::ID, framing::ANGLE), Some(-1.5));
|
|
assert_eq!(restored.param(framing::ID, framing::ROTATION), Some(1.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_sidecar_neither_records_nor_erases_the_files_orientation() {
|
|
// How a file stored its pixels is a fact about the file, so it must
|
|
// not travel in the sidecar — a shared edit would then carry one
|
|
// camera's sensor scan onto another's. Two failures are checked
|
|
// together because they are the same mistake seen from each end.
|
|
let mut sideways = EditGraph::default_chain();
|
|
sideways.set_orientation(dr_types::Orientation::from_exif(6));
|
|
|
|
// Nothing was edited, so there is nothing to write. If the baseline
|
|
// leaked into `param`, a rotation would appear here.
|
|
let v = version_of(&sideways);
|
|
let text = {
|
|
let mut s = Sidecar::new();
|
|
s.put(v);
|
|
s.to_text()
|
|
};
|
|
assert!(
|
|
!text.contains("framing.rotation"),
|
|
"an untouched sideways file wrote a rotation:\n{text}"
|
|
);
|
|
|
|
// And applying an edit — which resets the graph first — must leave the
|
|
// orientation where it was, or reopening an edited portrait frame
|
|
// shows it on its side.
|
|
let mut edited = EditGraph::default_chain();
|
|
edited.set_param(framing::ID, framing::ANGLE, -1.5);
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&edited));
|
|
Sidecar::parse(&sidecar.to_text())
|
|
.expect("valid")
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut sideways);
|
|
|
|
assert_eq!(
|
|
sideways.framing().baseline(),
|
|
dr_types::Orientation::from_exif(6)
|
|
);
|
|
assert_eq!(sideways.output_size(6000, 4000), (4000, 6000));
|
|
}
|
|
|
|
#[test]
|
|
fn applying_a_version_replaces_rather_than_overlays() {
|
|
// Loading an edit onto a graph that already holds one must not leave
|
|
// the previous image's exposure behind.
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&EditGraph::default_chain()));
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
|
|
let mut g = edited();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
assert!(g.is_neutral(), "a neutral version must clear the graph");
|
|
}
|
|
|
|
#[test]
|
|
fn writing_the_same_state_twice_is_byte_identical() {
|
|
// What lets a caller skip an upload by comparing content.
|
|
let mut a = Sidecar::new();
|
|
a.put(version_of(&edited()));
|
|
let once = a.to_text();
|
|
let twice = Sidecar::parse(&once).expect("valid").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-CAT-8
|
|
/// A file written before the tone curve had per-channel curves.
|
|
///
|
|
/// Spelled out as literal text rather than produced by `to_text`, because
|
|
/// the claim is about *those bytes*: a sidecar generated by this build
|
|
/// would agree with this build by construction, and would go on agreeing
|
|
/// with it through a rename that broke every file on disk.
|
|
#[test]
|
|
fn a_sidecar_from_before_the_channel_curves_still_names_the_master() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 4\nmodified = 9\n\
|
|
tone_curve.p1_y = 0.15\ntone_curve.p3_y = 0.85\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
|
|
// The S-curve the file describes, on the master curve and nowhere
|
|
// else.
|
|
assert_eq!(g.param(curve::ID, curve::P1_Y), Some(0.15));
|
|
assert_eq!(g.param(curve::ID, curve::P3_Y), Some(0.85));
|
|
for channel in [curve::Channel::Red, curve::Channel::Green, curve::Channel::Blue] {
|
|
for point in 0..curve::POINTS {
|
|
for axis in [curve::Axis::X, curve::Axis::Y] {
|
|
let id = curve::coordinate(channel, point, axis);
|
|
let expected = g
|
|
.capabilities()
|
|
.iter()
|
|
.find(|c| c.id == curve::ID)
|
|
.and_then(|c| c.params.iter().find(|p| p.id == id))
|
|
.map(|p| p.default);
|
|
assert_eq!(
|
|
g.param(curve::ID, id),
|
|
expected,
|
|
"{id} moved, and no line in the file mentions it"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// And writing it back produces the same two lines: the curves the file
|
|
// never mentioned are still at their defaults, so they are still
|
|
// absent (`only_non_default_values_are_written`).
|
|
let written = Sidecar::parse(&Sidecar::parse(text).expect("valid").to_text())
|
|
.expect("valid")
|
|
.to_text();
|
|
assert!(written.contains("tone_curve.p1_y = 0.15"), "{written}");
|
|
assert!(written.contains("tone_curve.p3_y = 0.85"), "{written}");
|
|
assert!(
|
|
!written.contains("tone_curve.r_"),
|
|
"an untouched channel curve was written out:\n{written}"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The other direction: the new curves persist like any other parameter.
|
|
#[test]
|
|
fn a_per_channel_curve_survives_the_round_trip() {
|
|
let mut g = EditGraph::default_chain();
|
|
// A faded shadow: blue lifted at the black point, red pulled down.
|
|
let blue = curve::coordinate(curve::Channel::Blue, 0, curve::Axis::Y);
|
|
let red = curve::coordinate(curve::Channel::Red, 4, curve::Axis::Y);
|
|
g.set_param(curve::ID, blue, 0.08);
|
|
g.set_param(curve::ID, red, 0.92);
|
|
g.set_param(curve::ID, curve::P2_Y, 0.55);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(version_of(&g));
|
|
let text = sidecar.to_text();
|
|
// Keyed by the channel-prefixed id, which is what makes the master's
|
|
// unprefixed ones safe to leave alone.
|
|
assert!(text.contains("tone_curve.b_p0_y = 0.08"), "{text}");
|
|
|
|
let parsed = Sidecar::parse(&text).expect("valid");
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.default_version()
|
|
.expect("a version")
|
|
.apply(&mut restored);
|
|
|
|
assert_eq!(restored.param(curve::ID, blue), Some(0.08));
|
|
assert_eq!(restored.param(curve::ID, red), Some(0.92));
|
|
assert_eq!(restored.param(curve::ID, curve::P2_Y), Some(0.55));
|
|
assert!(!restored.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_operation_survives_a_round_trip() {
|
|
// The data-loss case that matters: a device running an older build
|
|
// opens a file written by a newer one, saves, and must not delete
|
|
// the operation it never understood.
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 3\nmodified = 5\n\
|
|
exposure.exposure = 0.5\ntime_machine.year = 1994\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let written = parsed.to_text();
|
|
assert!(
|
|
written.contains("time_machine.year = 1994"),
|
|
"an unknown operation must not be dropped:\n{written}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_operation_does_not_reach_the_graph() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
time_machine.year = 1994\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
assert!(g.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn a_corrupt_value_costs_its_line_and_not_the_file() {
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
exposure.exposure = NaN\nwhite_balance.temperature = 20\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
|
assert_eq!(
|
|
g.param(white_balance::ID, white_balance::TEMPERATURE),
|
|
Some(20.0)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_out_of_range_value_is_clamped_rather_than_trusted() {
|
|
// A sidecar written by a build with a wider range must not put an
|
|
// out-of-range value into a uniform.
|
|
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 1\nmodified = 0\n\
|
|
exposure.exposure = 99\n";
|
|
let parsed = Sidecar::parse(text).expect("valid");
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.default_version().expect("a version").apply(&mut g);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_newer_format_version_is_refused_rather_than_guessed() {
|
|
let err = Sidecar::parse("drsc 99\n").unwrap_err();
|
|
assert_eq!(err, ParseError::UnsupportedVersion(99));
|
|
}
|
|
|
|
#[test]
|
|
fn a_non_sidecar_is_rejected() {
|
|
assert_eq!(
|
|
Sidecar::parse("<?xml version=\"1.0\"?>").unwrap_err(),
|
|
ParseError::NotASidecar
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn several_versions_are_kept_apart() {
|
|
// FR-CAT-12: one image, several virtual copies, independent edits.
|
|
let mut sidecar = Sidecar::new();
|
|
let mut a = Version::from_graph("u1", "Colour", &edited());
|
|
a.is_default = true;
|
|
let mut mono = EditGraph::default_chain();
|
|
mono.set_param(saturation::ID, saturation::SATURATION, -100.0);
|
|
sidecar.put(a);
|
|
sidecar.put(Version::from_graph("u2", "Mono", &mono));
|
|
|
|
let parsed = Sidecar::parse(&sidecar.to_text()).expect("valid");
|
|
assert_eq!(parsed.versions.len(), 2);
|
|
assert_eq!(parsed.default_version().expect("default").name, "Colour");
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
parsed.versions["u2"].apply(&mut g);
|
|
assert_eq!(
|
|
g.param(saturation::ID, saturation::SATURATION),
|
|
Some(-100.0)
|
|
);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn update_bumps_the_revision() {
|
|
// FR-NC-9 resolves by revision; a write that did not bump it would
|
|
// lose to a stale remote.
|
|
let mut v = version_of(&EditGraph::default_chain());
|
|
let before = v.revision;
|
|
v.update(&edited(), "device-a", 1000);
|
|
assert_eq!(v.revision, before + 1);
|
|
assert_eq!(v.device, "device-a");
|
|
assert_eq!(v.modified, 1000);
|
|
}
|
|
|
|
#[test]
|
|
fn disjoint_edits_both_survive_a_merge() {
|
|
// FR-NC-9's motivating case, verbatim: a crop on one device and an
|
|
// exposure change on the other.
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
let mut lg = EditGraph::default_chain();
|
|
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
local.update(&lg, "device-a", 100);
|
|
|
|
let mut remote = base.clone();
|
|
let mut rg = EditGraph::default_chain();
|
|
rg.set_crop(crate::CropRect {
|
|
x: 0.0,
|
|
y: 0.0,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
remote.update(&rg, "device-b", 200);
|
|
|
|
let conflicts = local.merge(&remote, Some(&base));
|
|
assert!(conflicts.is_empty(), "disjoint edits must not conflict");
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.0));
|
|
assert_eq!(g.crop().width, 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn a_genuine_conflict_resolves_to_the_higher_revision() {
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
let mut lg = EditGraph::default_chain();
|
|
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
local.update(&lg, "device-a", 100);
|
|
|
|
let mut remote = base.clone();
|
|
let mut rg = EditGraph::default_chain();
|
|
rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
remote.update(&rg, "device-b", 200);
|
|
remote.revision = local.revision + 1;
|
|
|
|
let conflicts = local.merge(&remote, Some(&base));
|
|
assert_eq!(conflicts.len(), 1, "the same parameter, two values");
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(2.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_skewed_clock_cannot_beat_a_higher_revision() {
|
|
// Revision ahead of timestamp, deliberately: a device with a wrong
|
|
// clock must not silently overwrite real work.
|
|
let base = version_of(&EditGraph::default_chain());
|
|
|
|
let mut local = base.clone();
|
|
let mut lg = EditGraph::default_chain();
|
|
lg.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
local.update(&lg, "device-a", 100);
|
|
local.revision = 50;
|
|
|
|
let mut remote = base.clone();
|
|
let mut rg = EditGraph::default_chain();
|
|
rg.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
remote.update(&rg, "device-b", 999_999);
|
|
remote.revision = 2;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert_eq!(
|
|
g.param(exposure::ID, exposure::EXPOSURE),
|
|
Some(1.0),
|
|
"the far-future timestamp must not win against a higher revision"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_remote_reset_to_default_removes_the_parameter() {
|
|
// Deletion is an edit too: clearing exposure on another device must
|
|
// propagate, not be masked by the key simply being absent.
|
|
let mut base = version_of(&edited());
|
|
base.revision = 1;
|
|
|
|
let mut local = base.clone();
|
|
let mut remote = base.clone();
|
|
remote.update(&EditGraph::default_chain(), "device-b", 200);
|
|
|
|
local.merge(&remote, Some(&base));
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
local.apply(&mut g);
|
|
assert!(g.is_neutral(), "the remote reset must survive the merge");
|
|
}
|
|
|
|
#[test]
|
|
fn a_merge_is_newer_than_either_input() {
|
|
let base = version_of(&EditGraph::default_chain());
|
|
let mut local = base.clone();
|
|
local.revision = 4;
|
|
let mut remote = base.clone();
|
|
remote.revision = 9;
|
|
|
|
local.merge(&remote, Some(&base));
|
|
assert!(local.revision > 9, "a merged result must not look stale");
|
|
}
|
|
|
|
#[test]
|
|
fn a_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");
|
|
}
|
|
}
|