Keep the mask when the app closes, and when two devices disagree

The sidecar is authoritative — the catalog is a disposable index and the
RAW is never written — so a mask that does not round-trip is not a
persistence bug, it is lost work.

Layers get their own `[mask <version> <id>]` blocks rather than being
flattened into dotted keys. A layer is not a scalar: it carries a
selection, a geometry and a chain of its own, and encoding a region set as
`m1.region.0 = 12` would be neither readable nor mergeable. The version
uuid is repeated in the header instead of relying on the block following
its version, because "belongs to whichever version appeared above me" is a
relationship that hand-editing, merging and older builds each break
quietly.

Region ids sort and deduplicate on read rather than being trusted from the
file. The mask's identity is the *set*, so two devices writing the same
selection in different orders must produce the same mask rather than
argue about a difference that is not one.

Masks merge by layer id under FR-NC-9, which is the disjoint-survives rule
the parameters already follow one level up: a layer added on the phone and
one added on the desktop both survive. A layer *both* sides edited resolves
wholesale to the higher revision, because half of one selection plus half
of another's opacity is a layer neither person made. A remote deletion is
honoured, or a mask the user removed returns on every sync.

An unknown mask source is skipped rather than guessed at. Applying a newer
format's mask type as the nearest one this build knows would put a
confidently wrong adjustment on the photograph, which is worse than
applying none.

29 new tests. The interesting ones are about silence: a maskless version
clearing the previous image's layers, a bare selection persisting even
though it renders nothing, and a mask naming a version that is not in the
file being dropped instead of landing on whichever block was open.
This commit is contained in:
2026-08-22 08:39:17 +02:00
parent c6a846a1f9
commit 94cfea4748
3 changed files with 787 additions and 3 deletions
+27 -3
View File
@@ -11,7 +11,8 @@ use crate::descriptor::{
Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
use crate::framing::{CropRect, Framing};
use crate::operation::{compose_with_framing, ComposedShader, Operation};
use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation};
use crate::ops;
/// TRACES: FR-DEV-3a
@@ -74,6 +75,13 @@ pub struct EditGraph {
/// pixel that colour is read from — and changes the output's dimensions,
/// which no colour operation can do. See [`crate::framing`].
framing: Framing,
/// The local adjustments (FR-DEV-3).
///
/// Also apart from `ops`, and for a sharper reason than framing's: each
/// layer *contains* a chain of its own. Folding the stack into the global
/// list would make the list recursive and every consumer that walks it
/// have to know that some entries are really sub-graphs.
masks: MaskStack,
}
impl EditGraph {
@@ -94,9 +102,19 @@ impl EditGraph {
Self {
ops: ops::chain(),
framing: Framing::new(),
masks: MaskStack::new(),
}
}
/// The local adjustment stack.
pub fn masks(&self) -> &MaskStack {
&self.masks
}
pub fn masks_mut(&mut self) -> &mut MaskStack {
&mut self.masks
}
/// The framing — crop, straighten, rotation and flips.
///
/// Reached directly rather than through `set_param` because the crop is a
@@ -251,6 +269,10 @@ impl EditGraph {
}
}
self.framing.reset();
// Masks go too, and this is why `apply` can be a replacement rather
// than an overlay: a sidecar with no mask blocks means an edit with no
// local adjustments, not an edit that keeps whatever was on screen.
self.masks = MaskStack::new();
}
/// Set the crop rectangle. Clamped to keep it inside the frame.
@@ -282,7 +304,9 @@ impl EditGraph {
/// makes the framing active without changing the image, and reporting a
/// merely-zoomed image as edited would mark a clean file dirty.
pub fn is_neutral(&self) -> bool {
!self.ops.iter().any(|o| o.is_active()) && !self.framing.edits_image()
!self.ops.iter().any(|o| o.is_active())
&& !self.framing.edits_image()
&& self.masks.is_neutral()
}
/// Generate the fused shader for the current state, encoded to sRGB.
@@ -302,7 +326,7 @@ impl EditGraph {
/// graph renders to the screen and to a file in the same breath, and the
/// two want different answers.
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
compose_with_framing(&self.ops, &self.framing, output)
compose_full(&self.ops, &self.framing, output, &self.masks)
}
}