Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or found by a model. So the only way to hold back a sky was to draw a line near where it ended, and the only way to warm skin was to paint round it — both of which put the edit's edge where the photographer put a gesture rather than where the picture changes. A gradient across a treeline halos, and an adjustment traced round a face stops on the outline of a hand. MaskSource grows two variants that select by what a pixel *is*. Luminance carries two bounds on the perceptual tone scale plus a softness; Colour carries an arc of hue, a range of chroma, and one softness for every edge of both. Five floats and three, so they diff, sync and merge per field under FR-NC-9 exactly as a gradient's geometry does — the property a stored raster has none of, and the reason the model's coverage had to sit beside its source rather than inside it. The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the demosaiced source as a sixth binding and two new modes read it: decode, balance, pull a clipped photosite back to neutral, apply the camera matrix, then weigh the band. Nothing crosses to the CPU but the numbers and the matrix, and each mask texel averages its own footprint in the source, so a band lands on the tone an area is rather than on whichever texel a proxy grid happened to land on. The photograph it measures is the one the camera recorded, before this edit. A band over the edited result would slide out from under the edit as the edit was made — raising the highlights would change which pixels counted as highlights, and the slider would chase its own mask. Feather, falloff and morphology stay off a range layer, which is what `shapeable` already meant. All three are functions of the signed distance from a boundary, and a range has no boundary to be at a distance from; its edge is the softness of its own band, in the band's units. Offering them would be four controls that move and change nothing.
This commit is contained in:
@@ -1124,3 +1124,110 @@ fn a_real_edit_is_still_a_conflict_with_coverage_present() {
|
||||
vec![("mask".to_string(), "m1".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Range masks (FR-DEV-10)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A band is the whole of what a range mask is, so losing one edge of it is
|
||||
/// losing the mask — and losing it silently, because the layer still names a
|
||||
/// source and still draws something.
|
||||
#[test]
|
||||
fn range_masks_keep_their_bands() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
|
||||
let mut tone = MaskLayer::new("m1", MaskSource::luminance_range(0.62, 0.91, 0.2));
|
||||
tone.set_param("exposure", ParamId("exposure"), -0.75);
|
||||
graph.masks_mut().push(tone);
|
||||
|
||||
let mut colour = MaskLayer::new("m2", MaskSource::colour_range(0.07, 0.04, 0.2, 0.85, 0.11));
|
||||
colour.set_param("exposure", ParamId("exposure"), 0.25);
|
||||
graph.masks_mut().push(colour);
|
||||
|
||||
let restored = round_trip(&graph);
|
||||
let layers = restored.masks().layers();
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(
|
||||
layers[0].source,
|
||||
MaskSource::luminance_range(0.62, 0.91, 0.2)
|
||||
);
|
||||
assert_eq!(
|
||||
layers[1].source,
|
||||
MaskSource::colour_range(0.07, 0.04, 0.2, 0.85, 0.11)
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Two devices that made the same range mask must write the same bytes, or
|
||||
/// per-field merge (FR-NC-9) has a difference to arbitrate that is not one.
|
||||
#[test]
|
||||
fn writing_a_range_mask_twice_is_byte_identical() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let mut layer = MaskLayer::new("m1", MaskSource::skin_tones());
|
||||
layer.set_param("exposure", ParamId("exposure"), 0.4);
|
||||
graph.masks_mut().push(layer);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A band written the wrong way round — by hand, or by a build that let a
|
||||
/// drag cross the handles — is a mask that selects nothing. Ordering it on
|
||||
/// read is what makes that a recoverable file rather than a lost edit.
|
||||
#[test]
|
||||
fn a_crossed_band_is_ordered_on_read() {
|
||||
let text = "drsc 1\n\
|
||||
\n[version default]\n\
|
||||
name = Default\n\
|
||||
\n[mask default m1]\n\
|
||||
source = luminance\n\
|
||||
band = 0.9 0.2 0.1\n\
|
||||
exposure.exposure = 0.5\n";
|
||||
|
||||
let parsed = Sidecar::parse(text).expect("parse");
|
||||
let mut graph = EditGraph::default_chain();
|
||||
parsed
|
||||
.versions
|
||||
.get("default")
|
||||
.expect("version")
|
||||
.apply(&mut graph)
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(
|
||||
graph.masks().layers()[0].source,
|
||||
MaskSource::luminance_range(0.2, 0.9, 0.1),
|
||||
"the band came back as the one the numbers describe"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A range block with no band at all is a file from a build that spelled the
|
||||
/// key differently, or a hand edit that deleted a line. It reads back as the
|
||||
/// mask the panel would have made rather than as an empty selection, because
|
||||
/// an empty selection looks exactly like a mask that works and does nothing.
|
||||
#[test]
|
||||
fn a_range_mask_with_no_band_falls_back_to_the_default_one() {
|
||||
let text = "drsc 1\n\
|
||||
\n[version default]\n\
|
||||
name = Default\n\
|
||||
\n[mask default m1]\n\
|
||||
source = luminance\n\
|
||||
exposure.exposure = 0.5\n";
|
||||
|
||||
let parsed = Sidecar::parse(text).expect("parse");
|
||||
let mut graph = EditGraph::default_chain();
|
||||
parsed
|
||||
.versions
|
||||
.get("default")
|
||||
.expect("version")
|
||||
.apply(&mut graph)
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(graph.masks().layers()[0].source, MaskSource::highlights());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user