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>
596 lines
19 KiB
Rust
596 lines
19 KiB
Rust
//! Local adjustments must survive the sidecar.
|
|
//!
|
|
//! The sidecar is the authoritative store (ARCH §6.1) — 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, and the tests here
|
|
//! are about the ways that happens quietly rather than loudly.
|
|
|
|
use dr_pipeline::descriptor::ParamId;
|
|
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
|
use dr_pipeline::{EditGraph, Sidecar, Version};
|
|
|
|
fn regions(ids: &[u32]) -> MaskSource {
|
|
MaskSource::Regions {
|
|
signature: 0xdead_beef,
|
|
level: 300,
|
|
ids: ids.to_vec(),
|
|
}
|
|
}
|
|
|
|
/// A graph with one masked adjustment on it.
|
|
fn graph_with_mask() -> EditGraph {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = MaskLayer::new("m1", regions(&[3, 7, 12]));
|
|
layer.name = "Subject".into();
|
|
layer.set_param("exposure", ParamId("exposure"), 0.75);
|
|
graph.masks_mut().push(layer);
|
|
graph
|
|
}
|
|
|
|
fn round_trip(graph: &EditGraph) -> EditGraph {
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", graph));
|
|
|
|
let text = sidecar.to_text();
|
|
let parsed = Sidecar::parse(&text).expect("reparse");
|
|
|
|
let mut restored = EditGraph::default_chain();
|
|
parsed
|
|
.versions
|
|
.get("default")
|
|
.expect("version survived")
|
|
.apply(&mut restored);
|
|
restored
|
|
}
|
|
|
|
#[test]
|
|
fn a_region_mask_survives_a_round_trip() {
|
|
let graph = graph_with_mask();
|
|
let restored = round_trip(&graph);
|
|
|
|
let layers = restored.masks().layers();
|
|
assert_eq!(layers.len(), 1, "the layer came back");
|
|
|
|
let layer = &layers[0];
|
|
assert_eq!(layer.id, "m1");
|
|
assert_eq!(layer.name, "Subject");
|
|
assert_eq!(layer.source, regions(&[3, 7, 12]));
|
|
assert_eq!(layer.ops.iter().find(|o| o.descriptor().id.0 == "exposure").map(|o| o.param(ParamId("exposure"))), Some(0.75));
|
|
}
|
|
|
|
#[test]
|
|
fn writing_the_same_state_twice_is_byte_identical() {
|
|
// What lets a caller skip an upload by comparing content rather than
|
|
// trusting a dirty flag — the property the whole `versions` ordering
|
|
// exists for, now that masks are in the file too.
|
|
let graph = graph_with_mask();
|
|
let mut a = Sidecar::new();
|
|
a.put(Version::from_graph("default", "Default", &graph));
|
|
|
|
let once = a.to_text();
|
|
let twice = Sidecar::parse(&once).expect("reparse").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
#[test]
|
|
fn gradients_keep_their_geometry() {
|
|
let mut graph = EditGraph::default_chain();
|
|
|
|
let mut linear = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Linear {
|
|
centre: (0.25, 0.75),
|
|
angle: 1.25,
|
|
width: 0.4,
|
|
},
|
|
);
|
|
linear.set_param("exposure", ParamId("exposure"), -1.0);
|
|
graph.masks_mut().push(linear);
|
|
|
|
let mut radial = MaskLayer::new(
|
|
"m2",
|
|
MaskSource::Radial {
|
|
centre: (0.6, 0.4),
|
|
radii: (0.3, 0.15),
|
|
angle: -0.5,
|
|
feather: 0.65,
|
|
},
|
|
);
|
|
radial.set_param("exposure", ParamId("exposure"), 0.5);
|
|
graph.masks_mut().push(radial);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layers = restored.masks().layers();
|
|
assert_eq!(layers.len(), 2);
|
|
assert_eq!(layers[0].source, graph.masks().layers()[0].source);
|
|
assert_eq!(layers[1].source, graph.masks().layers()[1].source);
|
|
}
|
|
|
|
#[test]
|
|
fn flags_and_opacity_survive() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = MaskLayer::new("m1", regions(&[1]));
|
|
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
|
layer.invert = true;
|
|
layer.opacity = 0.35;
|
|
layer.enabled = false;
|
|
graph.masks_mut().push(layer);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
assert!(layer.invert);
|
|
assert!((layer.opacity - 0.35).abs() < 1e-6);
|
|
assert!(!layer.enabled, "a disabled layer must stay disabled, not vanish");
|
|
}
|
|
|
|
/// A selection's identity is the *set*. Two files naming the same regions in
|
|
/// different orders describe one mask and must compare equal, or the two
|
|
/// devices that wrote them will fight forever over a difference that is not
|
|
/// one.
|
|
#[test]
|
|
fn region_order_is_normalised_on_read() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[mask default m1]\n\
|
|
source = regions\n\
|
|
signature = 5\n\
|
|
level = 10\n\
|
|
regions = 12 3 7 3\n\
|
|
exposure.exposure = 1\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let layer = &parsed.versions["default"].masks.layers()[0];
|
|
assert_eq!(
|
|
layer.source,
|
|
MaskSource::Regions {
|
|
signature: 5,
|
|
level: 10,
|
|
ids: vec![3, 7, 12],
|
|
},
|
|
"ids sort and deduplicate on the way in"
|
|
);
|
|
}
|
|
|
|
/// The version skew case. An older build must not delete a mask type it has
|
|
/// never heard of *silently* — but neither may it apply one it cannot read.
|
|
#[test]
|
|
fn an_unknown_mask_source_is_skipped_not_guessed() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[mask default m1]\n\
|
|
source = luminosity\n\
|
|
threshold = 0.5\n\
|
|
exposure.exposure = 1\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
assert!(
|
|
parsed.versions["default"].masks.is_empty(),
|
|
"an unreadable mask must not become a wrong one"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_mask_naming_no_known_version_is_dropped() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[mask ghost m1]\n\
|
|
source = regions\n\
|
|
signature = 1\n\
|
|
level = 1\n\
|
|
regions = 1\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
assert!(parsed.versions["default"].masks.is_empty());
|
|
}
|
|
|
|
/// Masks name their version rather than relying on file order, so a block
|
|
/// that appears before its version still lands on it.
|
|
#[test]
|
|
fn a_mask_block_need_not_follow_its_version() {
|
|
let text = "drsc 1\n\
|
|
\n[mask second m1]\n\
|
|
source = regions\n\
|
|
signature = 1\n\
|
|
level = 1\n\
|
|
regions = 4 5\n\
|
|
exposure.exposure = 1\n\
|
|
\n[version first]\n\
|
|
name = First\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[version second]\n\
|
|
name = Second\n\
|
|
revision = 1\n\
|
|
modified = 0\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
assert!(parsed.versions["first"].masks.is_empty());
|
|
assert_eq!(
|
|
parsed.versions["second"].masks.len(),
|
|
1,
|
|
"the block named its version and should have reached it"
|
|
);
|
|
}
|
|
|
|
/// Loading is a replacement, not an overlay: opening an unedited image after
|
|
/// an edited one must not leave the previous image's masks on screen.
|
|
#[test]
|
|
fn applying_a_maskless_version_clears_existing_masks() {
|
|
let mut graph = graph_with_mask();
|
|
assert_eq!(graph.masks().len(), 1);
|
|
|
|
let plain = Version::from_graph("clean", "Clean", &EditGraph::default_chain());
|
|
plain.apply(&mut graph);
|
|
assert!(graph.masks().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_graph_with_only_a_masked_edit_is_not_neutral() {
|
|
let graph = graph_with_mask();
|
|
assert!(!graph.is_neutral(), "a local adjustment is still an edit");
|
|
}
|
|
|
|
/// A bare selection with nothing applied to it changes no pixel, so the image
|
|
/// is unedited — but the layer must still be written, or the selection the
|
|
/// user made is lost on reload.
|
|
#[test]
|
|
fn a_selection_with_no_adjustment_still_persists() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(MaskLayer::new("m1", regions(&[9])));
|
|
assert!(graph.is_neutral(), "no adjustment means no pixel changes");
|
|
|
|
let restored = round_trip(&graph);
|
|
assert_eq!(
|
|
restored.masks().len(),
|
|
1,
|
|
"the selection is work and must survive even though it renders nothing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_top_level_keys_still_round_trip_alongside_masks() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
future.thing = 3\n\
|
|
\n[mask default m1]\n\
|
|
source = regions\n\
|
|
signature = 1\n\
|
|
level = 1\n\
|
|
regions = 2\n\
|
|
exposure.exposure = 1\n";
|
|
|
|
let out = Sidecar::parse(text).expect("parse").to_text();
|
|
assert!(out.contains("future.thing"), "unknown keys are still preserved");
|
|
assert!(out.contains("[mask default m1]"));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Brush strokes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// One gesture to paint: whether it erases, its radius, and its path.
|
|
type Gesture = (bool, f32, Vec<(f32, f32)>);
|
|
|
|
/// Paint gestures onto a fresh brush layer.
|
|
fn brushed(id: &str, gestures: &[Gesture]) -> MaskLayer {
|
|
let mut layer = MaskLayer::new(id, MaskSource::brush());
|
|
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
|
for (erase, radius, path) in gestures {
|
|
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
|
|
for &(x, y) in path {
|
|
layer.extend_stroke(x, y);
|
|
}
|
|
layer.end_stroke();
|
|
}
|
|
layer
|
|
}
|
|
|
|
/// The whole point of storing strokes as parameters: they must come back as
|
|
/// the *same numbers*, not as numbers that render similarly. A coordinate that
|
|
/// drifts in the sixth decimal on every save is a file that never stops
|
|
/// changing, and under per-field merge that is a conflict a day.
|
|
#[test]
|
|
fn strokes_survive_a_round_trip_exactly() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let painted = brushed(
|
|
"m1",
|
|
&[(
|
|
false,
|
|
0.0625,
|
|
vec![(0.1234, 0.5), (0.4, 0.2), (0.8, 0.75), (0.9, 0.1)],
|
|
)],
|
|
);
|
|
let expected = painted.strokes().to_vec();
|
|
graph.masks_mut().push(painted);
|
|
|
|
let restored = round_trip(&graph);
|
|
let layer = &restored.masks().layers()[0];
|
|
assert_eq!(layer.strokes(), expected.as_slice());
|
|
}
|
|
|
|
/// Order is the mask. An erase written before the add it was meant to cut into
|
|
/// would silently repaint what the user removed — the mask still looks like a
|
|
/// mask, so nothing announces it.
|
|
#[test]
|
|
fn stroke_order_and_direction_survive() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(brushed(
|
|
"m1",
|
|
&[
|
|
(false, 0.2, vec![(0.2, 0.5), (0.8, 0.5)]),
|
|
(true, 0.1, vec![(0.5, 0.5)]),
|
|
],
|
|
));
|
|
|
|
let restored = round_trip(&graph);
|
|
let strokes = restored.masks().layers()[0].strokes();
|
|
assert_eq!(strokes.len(), 2);
|
|
assert!(!strokes[0].erase, "the add must still come first");
|
|
assert!(strokes[1].erase, "and the erase second");
|
|
assert_eq!(strokes[1].radius, 0.1, "each stroke keeps its own brush");
|
|
}
|
|
|
|
#[test]
|
|
fn a_brush_layer_writes_one_line_per_stroke() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(brushed(
|
|
"m1",
|
|
&[
|
|
(false, 0.05, vec![(0.2, 0.5), (0.8, 0.5)]),
|
|
(true, 0.05, vec![(0.5, 0.5)]),
|
|
],
|
|
));
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let text = sidecar.to_text();
|
|
|
|
let lines: Vec<&str> = text
|
|
.lines()
|
|
.filter(|l| l.starts_with("stroke = "))
|
|
.collect();
|
|
assert_eq!(lines, ["stroke = add 0.05 0.5 1 0.2,0.5 0.8,0.5", "stroke = erase 0.05 0.5 1 0.5,0.5"]);
|
|
}
|
|
|
|
#[test]
|
|
fn writing_a_painted_mask_twice_is_byte_identical() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.masks_mut().push(brushed(
|
|
"m1",
|
|
&[(false, 0.05, vec![(0.2, 0.5), (0.4, 0.6), (0.8, 0.5)])],
|
|
));
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
sidecar.put(Version::from_graph("default", "Default", &graph));
|
|
let once = sidecar.to_text();
|
|
let twice = Sidecar::parse(&once).expect("reparse").to_text();
|
|
assert_eq!(once, twice);
|
|
}
|
|
|
|
/// One damaged line must not cost the strokes either side of it. Refusing the
|
|
/// whole layer would throw away a mask over a typo, and guessing at the missing
|
|
/// half would put paint where nobody touched.
|
|
#[test]
|
|
fn a_malformed_stroke_costs_only_that_stroke() {
|
|
let text = "drsc 1\n\
|
|
\n[version default]\n\
|
|
name = Default\n\
|
|
revision = 1\n\
|
|
modified = 0\n\
|
|
\n[mask default m1]\n\
|
|
source = brush\n\
|
|
stroke = add 0.05 0.5 1 0.2,0.5\n\
|
|
stroke = sideways 0.05 0.5 1 0.3,0.5\n\
|
|
stroke = add 0.05 0.5 1 0.4 0.5\n\
|
|
stroke = add 0.05 0.5 1 0.6,0.5\n\
|
|
exposure.exposure = 1\n";
|
|
|
|
let parsed = Sidecar::parse(text).expect("parse");
|
|
let strokes = parsed.versions["default"].masks.layers()[0].strokes();
|
|
assert_eq!(strokes.len(), 2, "the two readable strokes survived");
|
|
assert_eq!(strokes[0].points, vec![(0.2, 0.5)]);
|
|
assert_eq!(
|
|
strokes[1].points,
|
|
vec![(0.6, 0.5)],
|
|
"a point without its comma is refused rather than read as one number"
|
|
);
|
|
}
|
|
|
|
/// A brush layer with nothing painted on it is still work — the user made the
|
|
/// layer and set its adjustment — and must not disappear because it happens to
|
|
/// render nothing yet.
|
|
#[test]
|
|
fn an_unpainted_brush_layer_still_persists() {
|
|
let mut graph = EditGraph::default_chain();
|
|
let mut layer = MaskLayer::new("m1", MaskSource::brush());
|
|
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
|
graph.masks_mut().push(layer);
|
|
|
|
let restored = round_trip(&graph);
|
|
assert_eq!(restored.masks().len(), 1);
|
|
assert_eq!(restored.masks().layers()[0].source, MaskSource::brush());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync merge (FR-NC-9)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn version_with(uuid: &str, revision: u64, build: impl FnOnce(&mut EditGraph)) -> Version {
|
|
let mut graph = EditGraph::default_chain();
|
|
build(&mut graph);
|
|
let mut v = Version::from_graph(uuid, "Default", &graph);
|
|
v.revision = revision;
|
|
v
|
|
}
|
|
|
|
fn lit(id: &str, ids: &[u32], ev: f32) -> MaskLayer {
|
|
let mut layer = MaskLayer::new(id, regions(ids));
|
|
layer.set_param("exposure", ParamId("exposure"), ev);
|
|
layer
|
|
}
|
|
|
|
/// The case the whole key-wise merge exists for, one level up: a layer added
|
|
/// on the phone and a layer added on the desktop are not a conflict.
|
|
#[test]
|
|
fn disjoint_layers_from_two_devices_both_survive() {
|
|
let base = version_with("default", 1, |_| {});
|
|
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let theirs = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m2", &[2], -1.0));
|
|
});
|
|
|
|
let conflicts = ours.merge(&theirs, Some(&base));
|
|
assert!(conflicts.is_empty(), "different layers are not a conflict");
|
|
assert_eq!(ours.masks.len(), 2);
|
|
assert!(ours.masks.get("m1").is_some());
|
|
assert!(ours.masks.get("m2").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn a_layer_only_the_remote_added_arrives() {
|
|
let base = version_with("default", 1, |_| {});
|
|
let mut ours = version_with("default", 2, |_| {});
|
|
let theirs = version_with("default", 3, |g| {
|
|
g.masks_mut().push(lit("m1", &[5], 2.0));
|
|
});
|
|
|
|
assert!(ours.merge(&theirs, Some(&base)).is_empty());
|
|
assert_eq!(ours.masks.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn both_editing_one_layer_is_a_conflict_resolved_by_revision() {
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 0.5));
|
|
});
|
|
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let theirs = version_with("default", 9, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], -1.0));
|
|
});
|
|
|
|
let conflicts = ours.merge(&theirs, Some(&base));
|
|
assert_eq!(conflicts, vec![("mask".to_string(), "m1".to_string())]);
|
|
|
|
let layer = ours.masks.get("m1").expect("layer survived");
|
|
let ev = layer
|
|
.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id.0 == "exposure")
|
|
.map(|o| o.param(ParamId("exposure")));
|
|
assert_eq!(ev, Some(-1.0), "the higher revision wins the layer whole");
|
|
}
|
|
|
|
/// A layer deleted on one device and untouched on the other must stay
|
|
/// deleted, or a mask the user removed reappears on every sync.
|
|
#[test]
|
|
fn a_remote_deletion_is_honoured() {
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let theirs = version_with("default", 3, |_| {});
|
|
|
|
assert!(ours.merge(&theirs, Some(&base)).is_empty());
|
|
assert!(ours.masks.is_empty(), "the deletion should not be undone");
|
|
}
|
|
|
|
#[test]
|
|
fn an_untouched_layer_is_left_alone() {
|
|
let base = version_with("default", 1, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
let mut ours = version_with("default", 2, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
g.masks_mut().push(lit("m2", &[2], 0.5));
|
|
});
|
|
let theirs = version_with("default", 3, |g| {
|
|
g.masks_mut().push(lit("m1", &[1], 1.0));
|
|
});
|
|
|
|
assert!(ours.merge(&theirs, Some(&base)).is_empty());
|
|
assert_eq!(ours.masks.len(), 2, "our new layer is not a conflict");
|
|
}
|
|
|
|
/// Print a real sidecar carrying a subject mask, for eyeballing the format.
|
|
///
|
|
/// Ignored because it asserts nothing — it exists so the on-disk shape can be
|
|
/// looked at without reverse-engineering it from the writer.
|
|
///
|
|
/// ```sh
|
|
/// cargo test -p dr-pipeline --test mask_sidecar show_a_sidecar -- --ignored --nocapture
|
|
/// ```
|
|
#[test]
|
|
#[ignore = "prints the format rather than checking it"]
|
|
fn show_a_sidecar() {
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_param(dr_pipeline::OpId("exposure"), ParamId("exposure"), 0.35);
|
|
|
|
let mut subject = MaskLayer::new(
|
|
"m1",
|
|
MaskSource::Subject {
|
|
signature: 0x9f2c_41aa,
|
|
index: 0,
|
|
class: "person".into(),
|
|
score: 0.94,
|
|
},
|
|
);
|
|
subject.name = "person".into();
|
|
subject.invert = true;
|
|
subject.feather = 0.02;
|
|
subject.falloff = dr_pipeline::mask::Falloff::Gaussian;
|
|
subject.morphology = dr_pipeline::mask::Morphology::Dilate;
|
|
subject.morph_radius = 0.012;
|
|
subject.set_param("saturation", ParamId("saturation"), -100.0);
|
|
subject.set_param("exposure", ParamId("exposure"), -0.4);
|
|
graph.masks_mut().push(subject);
|
|
|
|
let mut grad = MaskLayer::new(
|
|
"m2",
|
|
MaskSource::Linear {
|
|
centre: (0.5, 0.25),
|
|
angle: std::f32::consts::FRAC_PI_2,
|
|
width: 0.4,
|
|
},
|
|
);
|
|
grad.set_param("exposure", ParamId("exposure"), -0.6);
|
|
graph.masks_mut().push(grad);
|
|
|
|
let mut brush = brushed(
|
|
"m3",
|
|
&[
|
|
(false, 0.06, vec![(0.31, 0.44), (0.35, 0.46), (0.4, 0.52)]),
|
|
(true, 0.03, vec![(0.36, 0.47)]),
|
|
],
|
|
);
|
|
brush.name = "dodge".into();
|
|
graph.masks_mut().push(brush);
|
|
|
|
let mut sidecar = Sidecar::new();
|
|
let mut v = Version::from_graph("default", "Default", &graph);
|
|
v.is_default = true;
|
|
v.rating = 4;
|
|
sidecar.put(v);
|
|
|
|
println!("\n{}", sidecar.to_text());
|
|
}
|