Apply the masks to the thumbnail and the export, not only the screen

Reported as a thumbnail bug; the export had it too, which is the serious
half. You would have exported a photograph missing every local adjustment.

Both called the unmasked `render`, and the failure is silent by
construction: the generated shader always declares the mask binding and
always emits a block per active layer, so binding the empty placeholder
multiplies each of them by zero. No error, no warning, no missing texture —
the adjustments are simply not there. From inside either path there is
nothing to see.

Every path that produces pixels now goes through one helper that binds the
array, and that is the point of it being one helper rather than three
correct call sites. The array is rasterised in source space at proxy size
and sampled through the framing map, so one array serves every output size:
a 256px thumbnail and a 24 MP export bind the same texture.

Three tests, and the first is the fault stated directly — render the same
edit with and without the array and assert they *differ*. If binding it ever
stops mattering, the masks have stopped reaching the shader. The third
checks the masked share of the frame is the same at 32px and 128px, because
"both non-empty" would pass while a mask that scaled wrongly still ruined
every thumbnail.
This commit is contained in:
2026-08-22 10:38:49 +02:00
parent 924a837389
commit 1d7106c94d
5 changed files with 248 additions and 21 deletions
+48 -19
View File
@@ -697,6 +697,38 @@ impl DevelopSession {
/// case and the one that must cost nothing: the adjust pass then binds its
/// own placeholder and the generated shader has no layer block to read it
/// with.
/// Render `shader` at `w`×`h` with this edit's masks bound.
///
/// **Every path that produces pixels must come through here.** The
/// generated shader always declares the mask binding and always emits a
/// layer block for each active layer; binding the empty placeholder
/// instead multiplies every one of them by zero. That is not an error and
/// logs nothing — the local adjustments simply are not there. Exports and
/// thumbnails both did exactly that.
///
/// The mask array is rasterised in source space at proxy size and sampled
/// through the framing map, so one array is correct at every output size:
/// a 256px thumbnail and a 24 MP export bind the same texture.
fn render_with_masks(
&mut self,
shader: &dr_pipeline::operation::ComposedShader,
w: u32,
h: u32,
) -> Result<(), String> {
let ctx = self.ctx.clone();
self.ensure_subject_fields(&ctx);
let masks = self
.rasterise_masks()
.then(|| self.masks.as_ref().and_then(|p| p.array()))
.flatten();
self.adjust
.render_masked(&self.demosaiced, shader, w, h, masks)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Returns whether the array is now valid for the current stack.
///
/// Split from reading the array back because the render below needs
@@ -843,8 +875,20 @@ impl DevelopSession {
// The model reads the photograph as captured, not as edited: the
// segmentation must survive an exposure change, or every slider would
// invalidate the masks that depend on it (docs/segmentation.md §3).
// Timed and logged, because this blocks the interface and the size of
// that stall is the feature's largest open risk. A number from the
// device it actually runs on beats an estimate from the desktop.
let started = std::time::Instant::now();
let (rgb, rw, rh) = self.neutral_proxy(ctx, SEGMENT_PROXY_EDGE)?;
let proxied = started.elapsed();
let seg = segmentation::compute(ctx, &rgb, rw, rh, options)?;
log::info!(
"segmented {rw}×{rh}: {} subject(s), proxy {:.0} ms, total {:.0} ms",
seg.instances().len(),
proxied.as_secs_f32() * 1000.0,
started.elapsed().as_secs_f32() * 1000.0,
);
if self.masks.is_none() {
self.masks = MaskPass::new(ctx)
@@ -1301,19 +1345,8 @@ impl DevelopSession {
// Rasterise the masks first: the shader addresses array slices by
// index, so the array has to describe *this* stack before it is bound.
// Any layer whose *shape* changed needs its field rebuilt before the
// rasteriser reads it. Keyed, so a feather drag reaches neither.
let ctx = self.ctx.clone();
self.ensure_subject_fields(&ctx);
let masks = self
.rasterise_masks()
.then(|| self.masks.as_ref().and_then(|p| p.array()))
.flatten();
let texture = self
.adjust
.render_masked(&self.demosaiced, &shader, w, h, masks)
.map_err(|e| e.to_string())?;
self.render_with_masks(&shader, w, h)?;
let texture = self.adjust.output().ok_or("nothing was rendered")?;
// The import is fallible on format and usage only, and both are fixed
// in `AdjustPass`'s texture descriptor — so a failure here is a
@@ -1436,9 +1469,7 @@ impl DevelopSession {
let (w, h) = self.graph.output_size(sw, sh);
let shader = self.graph.compose_for(space);
self.adjust
.render(&self.demosaiced, &shader, w, h)
.map_err(|e| e.to_string())?;
self.render_with_masks(&shader, w, h)?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string())
@@ -1465,9 +1496,7 @@ impl DevelopSession {
let (w, h) = fit(fw, fh, edge.max(1), edge.max(1));
let shader = self.graph.compose_for(dr_types::ColourSpace::Srgb);
self.adjust
.render(&self.demosaiced, &shader, w, h)
.map_err(|e| e.to_string())?;
self.render_with_masks(&shader, w, h)?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
Ok((rw, rh, pixels))