Show the photographer the mask they are shaping

Nobody can refine an edge they are not being shown. The only thing drawn on
the canvas was the region overlay — a false-coloured picture of what the model
*detected* — which knows nothing of a layer's feather, its falloff, its
morphology, its invert or its opacity, and nothing at all about a gradient, a
range or a stroke. Every control added for mask editing therefore acted on
something invisible, which is why the whole feature reads as absent rather
than as unfinished.

A layer's finished mask now draws over the photograph in one of three styles:
a tint for whether the right thing is selected, an alpha for where the edge
is, an outline for whether that edge is registered against the detail the
other two hide.

The hard part is not the shader. A selection with no adjustment on it changes
no pixel, so it is not active, so it holds no slice of the mask array and is
never rasterised — and that is exactly the layer somebody wants to look at,
for the whole of the time between choosing a subject and deciding what to do
to it. So `MaskStack::rendered` is `active()` plus the layer being looked at,
and the rasteriser, the composer and the distance-field builder all index by
position in it. Which is also why the design's "two uniforms, no recompile" is
not available: a uniform can select a slot, it cannot conjure one.

The reveal is never on the graph. It reaches the pipeline as an argument to
`compose_revealing`, and `compose_for` — which the exporter, the thumbnail and
the neutral probe all call — has no way to ask for one. A flag on the graph
would have been shorter, would have type-checked, and would have been one
forgotten reset away from a red tint baked into an exported file.

And the tools that shape a mask now arm. `Masking.tool` is an `in` property
only Rust may write, and the handler wrote nothing back, so the strip reported
"Select" however many times Paint was pressed and the paint area was never
enabled — the brush, the parts and the whole of FR-DEV-19b reachable from no
control in the application.

The region overlay stands down while a mask is being shown, and its button now
says what it hides: two overlays that look alike and mean different things is
worse than either.
This commit is contained in:
2026-09-10 20:28:06 +02:00
parent 193b35a249
commit c045702a47
10 changed files with 927 additions and 51 deletions
+28 -2
View File
@@ -708,10 +708,36 @@ impl MaskPass {
source: Option<&DemosaicedImage>,
width: u32,
height: u32,
) -> Result<&MaskArray, GpuError> {
self.render_revealing(stack, labels, subjects, source, width, height, None)
}
/// TRACES: FR-DEV-19c
/// [`Self::render`], also drawing the layer being looked at.
///
/// A selection with no adjustment on it changes no pixel, so it is not
/// active and has no slice — which is right until somebody asks to *see*
/// it, and that is the state a photographer is in from choosing a subject
/// until deciding what to do to it.
///
/// `reveal` has to be the same one the shader was composed with and the
/// same one the distance fields were built for: all three index this array
/// by position in [`MaskStack::rendered`], and two of them disagreeing
/// shows as an adjustment applied through another layer's mask.
#[allow(clippy::too_many_arguments)]
pub fn render_revealing(
&mut self,
stack: &MaskStack,
labels: Option<&LabelField>,
subjects: Option<&SubjectMasks>,
source: Option<&DemosaicedImage>,
width: u32,
height: u32,
reveal: Option<&dr_pipeline::mask::Reveal>,
) -> Result<&MaskArray, GpuError> {
// At least one layer, because a zero-layer texture array is invalid
// and the shader binds this slot unconditionally.
let active = stack.active_count().clamp(1, MAX_LAYERS) as u32;
let active = stack.rendered_count(reveal).clamp(1, MAX_LAYERS) as u32;
self.ensure_array(width, height, active)?;
let mut encoder = self
@@ -721,7 +747,7 @@ impl MaskPass {
label: Some("mask-encoder"),
});
for (slot, layer) in stack.active().enumerate().take(MAX_LAYERS) {
for (slot, layer) in stack.rendered(reveal).enumerate().take(MAX_LAYERS) {
// **The path a mask with one part takes is the path every mask
// took before parts existed**: drawn straight into the layer's
// slice, cleared by the draw itself. Nothing about an unedited
+197 -2
View File
@@ -12,8 +12,8 @@
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
use dr_pipeline::descriptor::ParamId;
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack};
use dr_pipeline::operation::compose_full;
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack, Reveal, RevealStyle};
use dr_pipeline::operation::{compose_full, compose_full_revealing};
use dr_pipeline::spot::SpotSet;
use dr_pipeline::{ops, EditGraph, Framing};
use dr_types::ColourSpace;
@@ -789,3 +789,198 @@ fn the_order_parts_are_joined_in_is_the_mask() {
"and subtracting after an addition takes it away again"
);
}
// --- seeing the mask (FR-DEV-19c) ------------------------------------------
/// A radial that covers the middle of the frame and nothing near the corners.
fn middle() -> MaskSource {
MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.3),
angle: 0.0,
feather: 0.05,
}
}
/// [`render`], with one layer's mask drawn over the result.
fn render_revealing(ctx: &GpuContext, stack: &MaskStack, reveal: &Reveal) -> Vec<u8> {
let source = grey(ctx);
let shader = compose_full_revealing(
&ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
stack,
&SpotSet::new(),
&[],
Some(reveal),
);
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks
.render_revealing(stack, None, None, None, SIZE, SIZE, Some(reveal))
.expect("rasterise");
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
/// TRACES: FR-DEV-19c
/// The state every mask is in for its first few seconds: chosen, and not yet
/// used for anything.
///
/// Such a layer changes no pixel, so it is not active, so it occupied no mask
/// slot and was never rasterised — and the reveal drew nothing. That is the
/// whole of "I clicked the category and nothing happened": there was a mask,
/// and no way to see that there was.
#[test]
fn a_selection_with_no_adjustment_can_still_be_seen() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("m1", middle()));
assert!(
stack.is_neutral(),
"the fixture must be a selection with nothing done to it"
);
let pixels = render_revealing(
&ctx,
&stack,
&Reveal {
layer: "m1".into(),
style: RevealStyle::Alpha,
},
);
assert!(
luma_at(&pixels, SIZE / 2, SIZE / 2) > 200,
"the middle is inside the mask and should read white"
);
assert!(
luma_at(&pixels, 1, 1) < 40,
"the corner is outside it and should read black"
);
}
/// And with nobody looking, the same stack changes nothing at all.
///
/// The other half of the property above: a layer renders *because* it is being
/// revealed, so it must stop when the reveal does — otherwise a selection with
/// no adjustment would leave a slice in the array for ever.
#[test]
fn a_mask_nobody_is_looking_at_draws_nothing() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("m1", middle()));
let pixels = render(&ctx, &stack, None);
assert_eq!(
luma_at(&pixels, SIZE / 2, SIZE / 2),
128,
"flat grey, exactly as it went in"
);
}
/// TRACES: FR-DEV-19c
/// A tint has to leave the photograph visible, or it cannot be judged against
/// it — which is the one thing an overlay exists for.
#[test]
fn a_tint_colours_the_mask_and_leaves_the_rest_alone() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("m1", middle()));
let pixels = render_revealing(
&ctx,
&stack,
&Reveal {
layer: "m1".into(),
style: RevealStyle::Tint,
},
);
let at = |x: u32, y: u32| {
let i = ((y * SIZE + x) * 4) as usize;
(pixels[i], pixels[i + 1], pixels[i + 2])
};
let (r, g, _) = at(SIZE / 2, SIZE / 2);
assert!(r > g + 40, "the mask should read red, got r={r} g={g}");
assert!(
g > 20,
"and not opaque — the photograph under it is what the tint is judged \
against, got g={g}"
);
let (r, g, b) = at(1, 1);
assert!(
(120..=136).contains(&r) && r == g && g == b,
"outside the mask the photograph is untouched, got ({r}, {g}, {b})"
);
}
/// TRACES: FR-DEV-19c
/// An outline draws where the mask stops and nowhere else — which is the
/// point of it, since the other two styles cover the detail the boundary has
/// to be judged against.
#[test]
fn an_outline_draws_the_boundary_and_not_the_interior() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
let mut stack = MaskStack::new();
stack.push(MaskLayer::new("m1", middle()));
let pixels = render_revealing(
&ctx,
&stack,
&Reveal {
layer: "m1".into(),
style: RevealStyle::Edge,
},
);
// Where the line landed, along the row through the centre. Searched
// rather than sampled at one place: the radial's edge crosses this row
// about 9.6 pixels out from the middle on a 32px frame, and asserting a
// particular pixel would be asserting the rounding.
let (at, brightest) = (SIZE / 2..SIZE)
.map(|x| (x, luma_at(&pixels, x, SIZE / 2)))
.max_by_key(|&(_, v)| v)
.expect("the row is not empty");
assert!(
brightest > 160,
"there should be a line somewhere on this row, brightest was {brightest}"
);
assert!(
(SIZE / 2 + 7..=SIZE / 2 + 12).contains(&at),
"and it should be on the mask's boundary, not somewhere else: x={at}"
);
assert_eq!(
luma_at(&pixels, SIZE / 2, SIZE / 2),
128,
"the picture inside the mask is untouched"
);
assert_eq!(
luma_at(&pixels, 1, 1),
128,
"and so is the picture outside it"
);
}
+29
View File
@@ -849,6 +849,35 @@ impl EditGraph {
)
}
/// TRACES: FR-DEV-19c
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
///
/// **The screen's composition, and only the screen's.** The reveal is not
/// on the graph and cannot be: it is how a photographer is looking at an
/// edit, not part of one, so it arrives as an argument to the one call
/// that draws the canvas. Every other path through this type composes
/// without it and could not ask for it if it wanted to.
///
/// The revealed layer renders whether or not it carries an adjustment —
/// which is the whole point, since a fresh selection carries none — so the
/// mask array must be rasterised for the same `reveal`. See
/// [`crate::mask::MaskStack::rendered`] for what the two have to agree on.
pub fn compose_revealing(
&self,
output: dr_types::ColourSpace,
reveal: Option<&crate::mask::Reveal>,
) -> ComposedShader {
crate::operation::compose_full_revealing(
&self.ops,
&self.framing,
output,
&self.masks,
&self.spots,
&self.warps,
reveal,
)
}
/// TRACES: FR-DSP-1
/// How this render relates to the file it stands for.
///
+231 -10
View File
@@ -1760,6 +1760,49 @@ impl MaskLayer {
}
}
/// TRACES: FR-DEV-19c
/// How a mask is drawn when the photographer asks to see it.
///
/// Three, because they answer three different questions and no one of them
/// answers all three — which is the argument for offering a choice rather than
/// picking the best one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RevealStyle {
/// The mask over the photograph in a flat colour. What every editor's
/// photographers already expect, and the only style that answers "is this
/// selecting the right thing" while the picture is still visible.
Tint,
/// The mask alone, white on black. For judging an edge, which a tint over
/// a busy photograph cannot be read against.
Alpha,
/// The boundary outlined over the untouched picture. For checking
/// registration against detail the other two hide — the same reasoning the
/// region overlay's white outline already carries.
Edge,
}
impl RevealStyle {
/// In the order the interface offers them.
pub const ALL: [RevealStyle; 3] = [Self::Tint, Self::Alpha, Self::Edge];
}
/// TRACES: FR-DEV-19c
/// The layer whose mask is being shown, and how.
///
/// **Never part of an edit.** It is not stored on [`MaskStack`] and it does
/// not travel with the graph: it is passed to the one composition that draws
/// the screen, so an export, a thumbnail and the neutral probe are
/// structurally unable to reveal anything. A flag on the stack would have been
/// fewer parameters and would have tinted every exported file red.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reveal {
/// Which layer, by id. By id rather than by slot because the slot is
/// derived from which layers render, and that is decided *by* this — see
/// [`MaskStack::rendered`].
pub layer: String,
pub style: RevealStyle,
}
/// The ordered stack of local adjustments.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MaskStack {
@@ -1838,6 +1881,34 @@ impl MaskStack {
self.active().count()
}
/// TRACES: FR-DEV-19c
/// [`Self::active`], plus the layer being looked at.
///
/// A selection with no adjustment on it yet is not active — it changes no
/// pixel, so it occupies no mask slot and the rasteriser never draws it.
/// That is right for rendering and exactly wrong for *showing* the mask,
/// which is the state a photographer is in for the whole of the time
/// between choosing a subject and deciding what to do to it.
///
/// So this is the sequence both halves walk whenever a reveal is in play,
/// and the index within it is the texture-array slot — the same contract
/// [`Self::active`] carries, and the reason the rasteriser, the composer
/// and the field builder must all be given the same `reveal` or none of
/// them. Two of them disagreeing shows as an adjustment applied through
/// another layer's mask.
pub fn rendered<'a>(
&'a self,
reveal: Option<&'a Reveal>,
) -> impl Iterator<Item = &'a MaskLayer> {
self.layers
.iter()
.filter(move |l| l.is_active() || reveal.is_some_and(|r| r.layer == l.id))
}
pub fn rendered_count(&self, reveal: Option<&Reveal>) -> usize {
self.rendered(reveal).count()
}
/// Whether any layer changes any pixel.
pub fn is_neutral(&self) -> bool {
self.active_count() == 0
@@ -1858,25 +1929,45 @@ pub(crate) struct LayerShader {
pub uniform_values: Vec<f32>,
pub body: String,
pub helpers: Vec<crate::operation::Helper>,
/// TRACES: FR-DEV-19c
/// The block that draws one layer's mask over the finished picture, empty
/// when nothing is being revealed.
///
/// Kept apart from `body` because it belongs at the other end of the
/// shader. Everything in `body` runs on scene-referred colour in the
/// working space, where a flat tint would then be pushed through the base
/// curve and the camera matrix and arrive as some other colour, and a
/// white-on-black alpha would arrive as neither. This runs after the
/// output transform, so what is written is what is seen.
pub reveal: String,
}
/// Emit the WGSL for every active layer.
/// Emit the WGSL for every layer that renders, and for the mask being looked
/// at.
///
/// `slot` is the layer's index in the mask texture array, matching
/// [`MaskStack::active`].
pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
/// [`MaskStack::rendered`] — the revealed layer renders whether or not it has
/// an adjustment on it, which is why the two are one sequence and why every
/// other half of the pipeline has to be given the same `reveal` for the slots
/// to mean the same thing.
pub(crate) fn compose_layers_revealing(stack: &MaskStack, reveal: Option<&Reveal>) -> LayerShader {
let mut out = LayerShader {
uniform_fields: String::new(),
uniform_values: Vec::new(),
body: String::new(),
helpers: Vec::new(),
reveal: String::new(),
};
if stack.active().next().is_some() {
if stack.rendered(reveal).next().is_some() {
out.helpers.push(MASK_SAMPLER);
}
for (slot, layer) in stack.active().enumerate() {
for (slot, layer) in stack.rendered(reveal).enumerate() {
if reveal.is_some_and(|r| r.layer == layer.id) {
out.reveal = reveal_block(slot, layer, reveal.expect("just matched").style);
}
let prefix = format!("mask{slot}");
let _ = writeln!(
@@ -1973,6 +2064,75 @@ pub(crate) fn compose_layers(stack: &MaskStack) -> LayerShader {
out
}
/// TRACES: FR-DEV-19c
/// The WGSL that draws one layer's mask over the finished picture.
///
/// # Why this is not two uniforms
///
/// The slot and the style are written into the source, so turning the reveal
/// on, off, or onto another layer recompiles the fused shader. That is a
/// button press rather than a frame — and the alternative costs more than it
/// saves: a uniform can select a slot, but it cannot conjure one for a layer
/// that is not rendering, and the whole reason this exists is that a selection
/// with no adjustment on it yet is exactly that layer. So the composition
/// changes either way, and a uniform would only have added a branch per pixel
/// on top of it.
///
/// **Everything below runs after the output transform.** `c` is already in the
/// output space's primaries and still linear — the clip and the encode come
/// after — which is what makes a stated colour arrive as itself.
fn reveal_block(slot: usize, layer: &MaskLayer, style: RevealStyle) -> String {
let prefix = format!("mask{slot}");
let mut out = format!(
"\n // ==== showing mask {slot}: {} ====\n //\n // Not part of the\
\n // photograph: this is the mask itself, drawn because someone asked to\
\n // see it. Nothing downstream of the screen composes this shader.\n {{\n",
layer.display_name()
);
// The shaped mask, exactly as the layer above applied it — the same two
// uniforms, in the same order. A reveal that showed the raw slice would
// draw a different mask from the one doing the work, which is worse than
// showing none: it would send a photographer to fix an edge that is
// already where they want it.
let _ = writeln!(out, " var m = sample_mask(uv_src, {slot});");
let _ = writeln!(
out,
" m = select(m, 1.0 - m, u.{prefix}_invert > 0.5);"
);
let _ = writeln!(out, " m = clamp(m * u.{prefix}_opacity, 0.0, 1.0);");
let body = match style {
// Red at a bit over half strength. Half is the strength every editor
// settled on for the same reason: past it the tint is opaque enough to
// hide the thing being judged, and below it a mask over a bright sky
// cannot be seen at all.
RevealStyle::Tint => " c = mix(c, vec3<f32>(0.85, 0.10, 0.15), m * 0.55);",
// Neutral, which means the same thing in every output space that
// shares a white point — so this one style needs no correction for the
// panel the window happens to be on.
RevealStyle::Alpha => " c = vec3<f32>(m);",
// The gradient's magnitude, over the untouched picture. Central
// differences one texel apart in the *mask's* own grid, so the outline
// is one mask texel wide however far the view is zoomed in — the
// boundary's position is the thing being checked, and a line that grew
// with the zoom would hide it.
RevealStyle::Edge => {
" let texel = 1.0 / vec2<f32>(textureDimensions(masks));\n\
\x20 let dx = sample_mask(uv_src + vec2<f32>(texel.x, 0.0), SLOT)\n\
\x20 - sample_mask(uv_src - vec2<f32>(texel.x, 0.0), SLOT);\n\
\x20 let dy = sample_mask(uv_src + vec2<f32>(0.0, texel.y), SLOT)\n\
\x20 - sample_mask(uv_src - vec2<f32>(0.0, texel.y), SLOT);\n\
\x20 // Doubled so a soft edge, whose gradient is spread over many\n\
\x20 // texels and therefore shallow everywhere, still draws a line.\n\
\x20 let edge = clamp(2.0 * sqrt(dx * dx + dy * dy), 0.0, 1.0);\n\
\x20 c = mix(c, vec3<f32>(1.0), edge);"
}
};
let _ = writeln!(out, "{}", body.replace("SLOT", &slot.to_string()));
let _ = writeln!(out, " }}");
out
}
/// A stable fingerprint of a segmentation, for [`MaskSource::Regions`].
///
/// Built from the things that change what a region id *means* — the proxy
@@ -2021,7 +2181,7 @@ mod tests {
let mut stack = MaskStack::new();
stack.push(layer);
assert!(stack.is_neutral());
assert_eq!(compose_layers(&stack).body, "");
assert_eq!(compose_layers_revealing(&stack, None).body, "");
}
#[test]
@@ -2107,7 +2267,7 @@ mod tests {
stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0));
let shader = compose_layers(&stack);
let shader = compose_layers_revealing(&stack, None);
assert!(shader.body.contains("sample_mask(uv_src, 0)"));
assert!(shader.body.contains("sample_mask(uv_src, 1)"));
assert!(shader.body.contains("u.mask0_opacity"));
@@ -2124,7 +2284,7 @@ mod tests {
stack.push(off);
stack.push(lit_layer("m2", -1.0));
let shader = compose_layers(&stack);
let shader = compose_layers_revealing(&stack, None);
assert!(
shader.body.contains("sample_mask(uv_src, 0)"),
"the one active layer must use slot 0, not slot 1"
@@ -2132,13 +2292,74 @@ mod tests {
assert!(!shader.body.contains("sample_mask(uv_src, 1)"));
}
/// TRACES: FR-DEV-19c
/// The slot the reveal is given has to be the slot the layer renders
/// through, and a revealed layer renders even with nothing done to it.
///
/// Both halves in one assertion because the failure is the pair coming
/// apart: a reveal pointed at a slot the rasteriser did not draw shows
/// whatever was last in that slice, which reads as the mask being wrong
/// rather than as the reveal being wrong.
#[test]
fn a_revealed_layer_takes_a_slot_of_its_own() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
// No adjustment, so this changes no pixel and would ordinarily render
// through no slot at all.
stack.push(MaskLayer::new("m2", MaskSource::brush()));
let reveal = Reveal {
layer: "m2".into(),
style: RevealStyle::Alpha,
};
let shader = compose_layers_revealing(&stack, Some(&reveal));
assert_eq!(
stack.rendered_count(Some(&reveal)),
2,
"the layer being looked at renders alongside the active one"
);
assert!(
shader.reveal.contains("sample_mask(uv_src, 1)"),
"the reveal must read slot 1, which is where m2 renders"
);
assert!(
shader.reveal.contains("u.mask1_opacity"),
"and shape it with that layer's own uniforms, not another's"
);
}
/// A composition nobody asked to see a mask through draws none.
#[test]
fn nothing_is_revealed_unless_it_was_asked_for() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
assert!(compose_layers_revealing(&stack, None).reveal.is_empty());
}
/// A reveal aimed at a layer that is not in the stack is not a slot, and
/// must not become one.
#[test]
fn a_reveal_naming_no_layer_reveals_nothing() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
let reveal = Reveal {
layer: "gone".into(),
style: RevealStyle::Tint,
};
assert_eq!(stack.rendered_count(Some(&reveal)), 1);
assert!(compose_layers_revealing(&stack, Some(&reveal))
.reveal
.is_empty());
}
#[test]
fn each_layer_gets_its_own_uniforms() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
stack.push(lit_layer("m2", -1.0));
let shader = compose_layers(&stack);
let shader = compose_layers_revealing(&stack, None);
assert!(shader.uniform_fields.contains("mask0_exposure_"));
assert!(shader.uniform_fields.contains("mask1_exposure_"));
assert_eq!(
@@ -2156,7 +2377,7 @@ mod tests {
fn the_inner_block_shadows_c_and_copies_back() {
let mut stack = MaskStack::new();
stack.push(lit_layer("m1", 1.0));
let body = compose_layers(&stack).body;
let body = compose_layers_revealing(&stack, None).body;
assert!(body.contains("var masked = c;"));
assert!(body.contains("var c = masked;"));
+80 -2
View File
@@ -551,6 +551,28 @@ pub fn compose_full(
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
) -> ComposedShader {
compose_full_revealing(ops, framing, output, masks, spots, warps, None)
}
/// TRACES: FR-DEV-19c
/// [`compose_full`], with one layer's mask drawn over the finished picture.
///
/// Separate from [`compose_full`] rather than an argument on it, and that is
/// the safety property rather than a convenience: the reveal is a thing the
/// screen does, and every other consumer of the pipeline — the exporter, the
/// thumbnail, the neutral probe — calls the function that has no way to ask
/// for it. A flag reachable from the graph would have been one forgotten reset
/// away from a red tint baked into an exported file.
#[allow(clippy::too_many_arguments)]
pub fn compose_full_revealing(
ops: &[Box<dyn Operation>],
framing: &Framing,
output: ColourSpace,
masks: &MaskStack,
spots: &crate::spot::SpotSet,
warps: &[Box<dyn crate::lens::Warp>],
reveal: Option<&crate::mask::Reveal>,
) -> ComposedShader {
// The lens corrections, composed into one coordinate transform. Beside
// `framing` because they are the other half of the same stage: framing
@@ -715,7 +737,13 @@ pub fn compose_full(
// from. Their uniforms follow the global ops' in the block for the same
// reason those follow framing's — slot order is emission order, and
// nothing addresses a slot by number.
let layers = crate::mask::compose_layers(masks);
let layers = crate::mask::compose_layers_revealing(masks, reveal);
// TRACES: FR-DEV-19c
// Held apart from the body, because it belongs after the output transform
// rather than among the operations — see `mask::LayerShader::reveal`.
// Empty for every composition nobody is looking at a mask through, which
// is all of them but the screen's.
let reveal_block = layers.reveal.clone();
uniform_fields.push_str(&layers.uniform_fields);
uniform_values.extend_from_slice(&layers.uniform_values);
body.push_str(&layers.body);
@@ -975,7 +1003,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
c = mix(c, neutral, clipped);
}}
{body}
{rendering_tail}{to_output}
{rendering_tail}{to_output}{reveal_block}
{store}
}}
",
@@ -1569,6 +1597,56 @@ mod tests {
);
}
/// TRACES: FR-DEV-19c
/// **The property that keeps a reveal off an exported file.**
///
/// `compose_full` is the entry point the exporter, the thumbnail and the
/// neutral probe all use, and it has no argument that could ask for a
/// mask overlay. Only `compose_full_revealing` does, and only the canvas
/// calls it. Asserted rather than left to the type signature because the
/// tempting simplification — a flag on the graph — would type-check, be
/// shorter, and bake a red tint into every file the photographer sold.
#[test]
fn an_ordinary_composition_cannot_draw_a_mask_over_the_picture() {
use crate::mask::{MaskLayer, MaskSource, Reveal, RevealStyle};
let mut stack = MaskStack::new();
let mut layer = MaskLayer::new("m1", MaskSource::brush());
layer.set_param("exposure", crate::descriptor::ParamId("exposure"), 1.0);
stack.push(layer);
let plain = compose_full(
&crate::ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&stack,
&crate::spot::SpotSet::new(),
&[],
);
assert!(
!plain.source.contains("==== showing mask"),
"an export must never carry the overlay"
);
let shown = compose_full_revealing(
&crate::ops::chain(),
&Framing::new(),
ColourSpace::Srgb,
&stack,
&crate::spot::SpotSet::new(),
&[],
Some(&Reveal {
layer: "m1".into(),
style: RevealStyle::Tint,
}),
);
assert!(shown.source.contains("==== showing mask"));
assert_ne!(
plain.structure_hash, shown.structure_hash,
"two different shaders must not share a pipeline cache entry"
);
}
/// Distortion alone samples once; chromatic aberration samples three times.
///
/// `splits_channels` is the whole reason for this test. Lateral CA fetches