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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user