Merge branch 'worktree-agent-acd27f9b2974c67eb' into integration

# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	core/dr-pipeline/src/detail.rs
#	core/dr-pipeline/src/ops/mod.rs
This commit is contained in:
2026-08-22 19:31:15 +02:00
7 changed files with 1661 additions and 7 deletions
+27 -6
View File
@@ -891,6 +891,11 @@ mod tests {
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
use dr_pipeline::EditGraph;
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
// asks the chain which of its operations are neighbourhood operations
// rather than being told a list. Imported anonymously: nothing here names
// the trait, only calls through it.
use dr_pipeline::Operation as _;
use crate::Demosaicer;
@@ -1454,8 +1459,20 @@ mod tests {
// allocation would show up.
let (w, h) = g.output_size(32, 32);
let shader = g.compose();
let scale = g.render_scale(img.size(), (w, h));
let detail = g.compose_detail(scale);
// At 512 rather than the 32 this test used before the detail stage
// existed, and the size is load-bearing twice over. A compositional
// radius is a fraction of the frame, so on a 32-pixel target every
// detail kernel rounds to nothing: the chain would compose no passes,
// leaving half the shader uncompiled, and the exclusive-or below would
// find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512);
let scale = g.render_scale((512, 512), (w, h));
let detail = g.compose_detail_for(scale, dr_types::ColourSpace::Srgb);
assert!(
!detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled"
);
// Every operation has to reach the pipeline, but they do not all reach
// the same half of it, and which half is not this test's business to
@@ -1494,10 +1511,14 @@ mod tests {
"with every operation active the detail stage must run"
);
// Both halves, from the one graph: with a detail stage present the
// fused pass stops at linear working values and the last detail pass
// performs the output transform, so rendering only the first half is
// not a smaller test — it is a texture format the driver rejects.
// The other half of the same edit, and it belongs in this test for the
// reason the test exists: the detail passes are generated WGSL too,
// they carry their own uniform blocks, and "everything at once" is
// exactly where a collision between them would show. Rendering the
// fused half alone is no longer even legal — with a neighbourhood
// operation active the fused pass stops at linear working values and
// the last detail pass performs the output transform, which is the
// mismatch `render_detailed` exists to reject.
let key = g.invalidation().through(dr_pipeline::Affects::Colour);
pass.render_detailed(&img, &shader, w, h, None, &detail, key)
.expect("the full chain must compile");
+607
View File
@@ -0,0 +1,607 @@
//! Clarity and texture, end to end on a real device.
//!
//! `dr-pipeline`'s tests assert what the composer *generates* — the kernel
//! width, the uniforms, which lines of WGSL each node emits. None of that can
//! tell whether the two passes compose into an unsharp mask, whether the
//! original colour really survives the hand-off from the blur pass to the
//! combining one, or whether the halo the soft limit is supposed to bound is
//! actually bounded in pixels. Those are questions only a GPU answers.
//!
//! # Why every measurement is in stops
//!
//! The controls work on log luminance, and their guarantees are stated in
//! stops: an overshoot of at most `gain * threshold`, an effect that is
//! symmetric about neutral, a strength that does not depend on how bright the
//! subject is. Asserting on 8-bit code values would restate all of that in a
//! unit where none of it is true, and would need a fresh magic number for
//! every brightness tested. So the pixels are decoded back to linear and
//! compared as ratios.
//!
//! # The test image
//!
//! A vertical step between two **midtones** rather than between black and
//! white. Clarity is tapered to nothing at both ends of the range on purpose
//! (see `midtone_weight`), so a 0–255 step is the one edge in the world it is
//! designed to leave alone, and a test built on it would measure the taper
//! working and call it the feature not working.
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext};
use dr_pipeline::descriptor::{OpId, ParamId};
use dr_pipeline::ops::local_contrast::{Clarity, Texture};
use dr_pipeline::{Affects, EditGraph, OutputMode};
use dr_types::ColourSpace;
const CLARITY: OpId = OpId("clarity");
const TEXTURE: OpId = OpId("texture");
const AMOUNT: ParamId = ParamId("amount");
/// Large enough that texture's kernel — a tenth of clarity's — is still more
/// than one pixel wide. At 1024 its sigma is 1.2 px; at 256 it would round to
/// a delta and the control would honestly do nothing, which is the behaviour
/// `texture_stops_rather_than_lying_when_the_render_is_too_small` covers and
/// not the behaviour under test here.
const SIZE: u32 = 1024;
/// The two sides of the step, as sRGB code values.
///
/// Both well inside the range, and roughly two stops apart — a real edge, of
/// the kind that produces the halo this file exists to bound.
const DARK: u8 = 90;
const BRIGHT: u8 = 175;
fn ctx() -> Option<GpuContext> {
// CI runners and headless machines may have no usable adapter. Skip rather
// than fail, exactly as the rest of this crate's device tests do.
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
fn srgb_decode(v: u8) -> f32 {
let e = v as f32 / 255.0;
if e <= 0.040_45 {
e / 12.92
} else {
((e + 0.055) / 1.055).powf(2.4)
}
}
/// A vertical step from `DARK` to `BRIGHT` at the half-way column.
fn step_edge(ctx: &GpuContext, size: u32, tint: [f32; 3]) -> DemosaicedImage {
let data: Vec<u8> = (0..size * size)
.flat_map(|i| {
let x = i % size;
let v = if x < size / 2 { DARK } else { BRIGHT } as f32;
[
(v * tint[0]).round() as u8,
(v * tint[1]).round() as u8,
(v * tint[2]).round() as u8,
255,
]
})
.collect();
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
/// One row of the rendered image, as linear luminance-ish red values.
fn row(pixels: &[u8], size: u32, y: u32) -> Vec<u8> {
(0..size)
.map(|x| pixels[((y * size + x) * 4) as usize])
.collect()
}
/// One row as full RGB triples.
fn row_rgb(pixels: &[u8], size: u32, y: u32) -> Vec<[u8; 3]> {
(0..size)
.map(|x| {
let i = ((y * size + x) * 4) as usize;
[pixels[i], pixels[i + 1], pixels[i + 2]]
})
.collect()
}
/// Render one graph with its detail stage and read the pixels back.
fn render(pass: &mut AdjustPass, graph: &EditGraph, source: &DemosaicedImage, out: u32) -> Vec<u8> {
let shader = graph.compose_for(ColourSpace::Srgb);
let scale = graph.render_scale(source.size(), (out, out));
let detail = graph.compose_detail_for(scale, ColourSpace::Srgb);
let key = graph.invalidation().through(Affects::Colour);
pass.render_detailed(source, &shader, out, out, None, &detail, key)
.expect("render");
pass.export_pixels().expect("readback").0
}
/// A graph with one of the two controls set and everything else neutral.
fn graph_with(op: OpId, amount: f32) -> EditGraph {
let mut g = EditGraph::default_chain();
g.set_param(op, AMOUNT, amount);
g
}
/// How far a pixel moved, in stops, against the same pixel unedited.
fn stops(edited: u8, plain: u8) -> f32 {
(srgb_decode(edited).max(1e-6) / srgb_decode(plain).max(1e-6)).log2()
}
#[test]
fn clarity_lifts_local_contrast_and_leaves_the_flat_regions_alone() {
// The definition of a local contrast control, as pixels: it must do
// something at the edge and *nothing* a long way from it. An operation
// that brightened the whole bright plateau would be an exposure slider
// with extra steps, and it is the failure a sign error in the base
// produces.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut pass = AdjustPass::new(&ctx);
let edited = row(
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
let edge = (SIZE / 2) as usize;
let reach = Clarity::with_amount(100.0)
.kernel(EditGraph::default_chain().render_scale((SIZE, SIZE), (SIZE, SIZE)))
as usize;
// Far outside the kernel's reach the base equals the pixel, the detail
// signal is zero, and the output must be the input to the last code value.
for x in [0, reach / 2, SIZE as usize - 1 - reach / 2, SIZE as usize - 1] {
assert!(
edited[x].abs_diff(plain[x]) <= 1,
"column {x} moved by {} away from any edge",
edited[x].abs_diff(plain[x])
);
}
// And at the edge it must do the thing it is for: the bright side lifts,
// the dark side drops, which is what "more local contrast" means.
assert!(
edited[edge] > plain[edge] + 4,
"the bright side of the edge did not lift: {} vs {}",
edited[edge],
plain[edge]
);
assert!(
edited[edge - 1] + 4 < plain[edge - 1],
"the dark side of the edge did not drop: {} vs {}",
edited[edge - 1],
plain[edge - 1]
);
}
#[test]
fn the_soft_limit_bounds_the_halo_at_a_hard_edge() {
// The single most common way clarity is got wrong, held to a number.
//
// `t * tanh(d / t)` saturates at `t`, so no pixel may move further than
// `gain * threshold` stops however violent the edge — a bound that holds
// by construction rather than by tuning, and one this test takes from the
// operation itself rather than restating.
//
// The comparison that gives it meaning is the second assertion: an
// unlimited unsharp mask over this edge would move the bright side by
// about half the step, which is more than twice as far. That is the
// difference between a control and a white glow along the skyline.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut pass = AdjustPass::new(&ctx);
let edited = row(
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
let worst = (0..SIZE as usize)
.map(|x| stops(edited[x], plain[x]).abs())
.fold(0.0f32, f32::max);
let bound = Clarity::with_amount(100.0).overshoot_bound();
// Where the two comparisons below sit, derived rather than observed:
//
// srgb_decode(175) = 0.4287, srgb_decode(90) = 0.1022
// the step is log2(0.4287 / 0.1022) = 2.069 stops
// an unlimited mask peaks at half of it = 1.034 stops
// the soft limit saturates at = 0.350 stops (`bound`)
// the midtone taper then takes about 13% off at 175, so the peak this
// test should actually see is near = 0.30 stops
//
// So 0.30 has to clear the 0.1 floor with room, and fall well under both
// 0.35 + slack and 0.6 × 1.034 = 0.62. Every one of those is a bound with
// a reason, not a tolerance widened until the test passed.
//
// A code value's worth of slack: the readback is 8-bit, and a pixel
// sitting exactly on the bound quantises either side of it.
assert!(
worst <= bound + 0.02,
"a pixel moved {worst:.3} stops, past the {bound:.3} the soft limit \
promises"
);
// Half the step is what an unlimited mask would have produced at the very
// edge, since the base there is the mean of the two plateaus.
let unlimited = (srgb_decode(BRIGHT) / srgb_decode(DARK)).log2() / 2.0;
assert!(
worst < unlimited * 0.6,
"the limit is not biting: {worst:.3} stops against the {unlimited:.3} \
an unlimited unsharp mask would give"
);
// But it is still a real effect, not a control that does nothing.
assert!(worst > 0.1, "clarity moved almost nothing: {worst:.3} stops");
}
#[test]
fn a_proxy_and_an_export_agree_about_the_effect() {
// TRACES: FR-DSP-1 — the decision the radius unit rests on, proved in
// pixels rather than in kernel widths.
//
// Clarity's radius is a fraction of the frame because the control is
// compositional: "separate the subject from its background" is a statement
// about how much of the picture the subject occupies. If that is right,
// the *same edit* rendered at two resolutions must produce an effect of
// the same strength covering the same proportion of the frame — which is
// exactly what a photographer tuning on screen and exporting at full size
// is relying on.
//
// Had the radius been stated in source pixels, the proxy here would show
// half the reach and the export would be a different photograph.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
// Peak excursion in stops, and how far the effect reaches, as a fraction
// of the frame.
let measure = |out: u32| -> (f32, f32) {
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, out),
out,
out / 2,
);
let mut pass = AdjustPass::new(&ctx);
let edited = row(
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, out),
out,
out / 2,
);
let moved: Vec<f32> = (0..out as usize)
.map(|x| stops(edited[x], plain[x]).abs())
.collect();
let peak = moved.iter().cloned().fold(0.0f32, f32::max);
// The width of the band that moved by more than a tenth of the peak —
// a threshold relative to the effect, so it means the same thing at
// both sizes.
let touched = moved.iter().filter(|m| **m > peak * 0.1).count();
(peak, touched as f32 / out as f32)
};
let (proxy_peak, proxy_reach) = measure(SIZE / 2);
let (export_peak, export_reach) = measure(SIZE);
assert!(
(proxy_peak - export_peak).abs() < 0.03,
"the same edit is {proxy_peak:.3} stops on the proxy and \
{export_peak:.3} in the export"
);
assert!(
(proxy_reach - export_reach).abs() < 0.02,
"the effect covers {proxy_reach:.3} of the proxy and {export_reach:.3} \
of the export; a radius tuned on screen must land in the file"
);
// And it is a real effect at both sizes, not two flat images agreeing.
assert!(
proxy_peak > 0.1 && proxy_reach > 0.02,
"{proxy_peak:.3} stops over {proxy_reach:.3} of the proxy"
);
}
#[test]
fn texture_acts_at_a_finer_scale_than_clarity() {
// The whole reason there are two nodes. If the two controls ever reach the
// same distance from an edge, the second slider has become a duplicate of
// the first and a photographer setting both is setting one thing twice.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let reach = |op: OpId| -> usize {
let mut pass = AdjustPass::new(&ctx);
let edited = row(
&render(&mut pass, &graph_with(op, 100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
// How many columns moved by more than a code value — the honest
// measure of "how far from the edge does this control reach".
(0..SIZE as usize)
.filter(|&x| edited[x].abs_diff(plain[x]) > 1)
.count()
};
let coarse = reach(CLARITY);
let fine = reach(TEXTURE);
assert!(fine > 0, "texture did nothing at all");
assert!(
coarse > fine * 4,
"clarity reaches {coarse} columns and texture {fine}; these are not \
separable scales"
);
}
#[test]
fn clarity_moves_luminance_without_moving_hue() {
// The third halo decision, in pixels. The gain is applied as a scale on
// the whole triple, so chromaticity is untouched; boosting the channels
// independently would put a *coloured* fringe along every edge, arriving
// from a control the photographer reads as contrast.
let Some(ctx) = ctx() else { return };
// A strongly tinted step, so a per-channel mask would show plainly.
let source = step_edge(&ctx, SIZE, [1.0, 0.55, 0.25]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row_rgb(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut pass = AdjustPass::new(&ctx);
let edited = row_rgb(
&render(&mut pass, &graph_with(CLARITY, 100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
// Compare in linear light, where a scale is a scale. The two channel
// ratios together fix the chromaticity, so holding both fixes the colour.
let edge = (SIZE / 2) as usize;
for x in [edge, edge + 1, edge + 4, edge - 1, edge - 4] {
let ratio = |p: [u8; 3], i: usize| srgb_decode(p[i]) / srgb_decode(p[0]).max(1e-6);
for channel in [1, 2] {
let before = ratio(plain[x], channel);
let after = ratio(edited[x], channel);
assert!(
(after - before).abs() < 0.02,
"column {x} channel {channel}: chromaticity moved from \
{before:.4} to {after:.4} — that is a coloured fringe"
);
}
}
// And the effect was actually applied here, or the assertion above is
// vacuous.
assert!(edited[edge][0].abs_diff(plain[edge][0]) > 3);
}
#[test]
fn negative_clarity_softens_the_surface_without_dissolving_the_edge() {
// The soft limit earns its keep in both directions. An unlimited mask at
// −100 subtracts the whole detail signal and turns every edge to mud;
// limited, it removes at most the threshold, so modelling softens and real
// edges stand.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut pass = AdjustPass::new(&ctx);
let softened = row(
&render(&mut pass, &graph_with(CLARITY, -100.0), &source, SIZE),
SIZE,
SIZE / 2,
);
let edge = (SIZE / 2) as usize;
// The sign is the other way round from the positive case: the bright side
// of the edge comes down and the dark side comes up.
assert!(
softened[edge] + 3 < plain[edge],
"negative clarity did not soften: {} vs {}",
softened[edge],
plain[edge]
);
// But the step itself survives. Measured in stops across the edge, so the
// claim is about contrast and not about code values.
let step_of = |r: &[u8]| (srgb_decode(r[edge]) / srgb_decode(r[edge - 1])).log2();
let before = step_of(&plain);
let after = step_of(&softened);
assert!(
after > before * 0.55,
"the edge dissolved: {after:.3} stops left of {before:.3}"
);
}
#[test]
fn neutral_controls_cost_the_edit_nothing() {
// Both nodes are in the default chain, and both are the widest kernels in
// the pipeline. An unedited photograph must render through the single
// fused dispatch it always did — no detail pass, no intermediate texture,
// and byte-identical pixels.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, 128, [1.0, 1.0, 1.0]);
let graph = EditGraph::default_chain();
assert_eq!(
graph.compose_for(ColourSpace::Srgb).output_mode,
OutputMode::Encoded,
"a neutral detail operation must not change how the fused pass ends"
);
let mut pass = AdjustPass::new(&ctx);
render(&mut pass, &graph, &source, 128);
assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 0);
assert_eq!(pass.detail_allocations(), 0, "nothing was allocated");
}
#[test]
fn dragging_the_slider_re_runs_the_detail_stage_and_nothing_else() {
// TRACES: FR-DEV-3d. Clarity is `Affects::Detail`, so the fused colour
// pass's result is still valid while the slider moves — which for a
// hundred-tap kernel is the difference between an interactive control and
// a slideshow. Invisible in the output by construction, so a dispatch
// counter is the only thing that can see it.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, 256, [1.0, 1.0, 1.0]);
let mut pass = AdjustPass::new(&ctx);
let mut graph = graph_with(CLARITY, 40.0);
render(&mut pass, &graph, &source, 256);
assert_eq!(pass.colour_dispatches(), 1);
assert_eq!(pass.detail_dispatches(), 2, "a separable mask is two passes");
let pipelines = pass.cached_detail_pipelines();
for amount in [50.0, 60.0, 70.0] {
graph.set_param(CLARITY, AMOUNT, amount);
render(&mut pass, &graph, &source, 256);
}
assert_eq!(
pass.colour_dispatches(),
1,
"the fused colour pass re-ran for a change it does not depend on"
);
assert_eq!(pass.detail_dispatches(), 8);
assert_eq!(
pass.cached_detail_pipelines(),
pipelines,
"an amount is a uniform, not a shader"
);
// Turning on the other control adds its own pair, and only its own pair.
graph.set_param(TEXTURE, AMOUNT, 40.0);
render(&mut pass, &graph, &source, 256);
assert_eq!(pass.detail_dispatches(), 12);
assert_eq!(pass.colour_dispatches(), 1);
}
#[test]
fn the_two_controls_stack_without_overwriting_each_other() {
// Four passes through one ping-pong, with the scratch lane changing hands
// half way. If clarity's combining pass left the colour where its blur
// pass had put it — or if texture's blur overwrote the colour rather than
// the lane — the result would be a blurred image rather than a sharpened
// one, which is loud rather than subtle.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, SIZE, [1.0, 1.0, 1.0]);
let mut plain_pass = AdjustPass::new(&ctx);
let plain = row(
&render(&mut plain_pass, &EditGraph::default_chain(), &source, SIZE),
SIZE,
SIZE / 2,
);
let mut graph = graph_with(CLARITY, 80.0);
graph.set_param(TEXTURE, AMOUNT, 80.0);
let mut pass = AdjustPass::new(&ctx);
let both = row(&render(&mut pass, &graph, &source, SIZE), SIZE, SIZE / 2);
assert_eq!(pass.detail_dispatches(), 4);
let edge = (SIZE / 2) as usize;
// Both sides of the edge move the way local contrast moves them...
assert!(both[edge] > plain[edge] + 4);
assert!(both[edge - 1] + 4 < plain[edge - 1]);
// ...and the plateaus are untouched, which a stray blur would not leave.
assert!(both[0].abs_diff(plain[0]) <= 1);
assert!(both[SIZE as usize - 1].abs_diff(plain[SIZE as usize - 1]) <= 1);
// Stacked, they must reach further than either alone — the coarse control
// still working at its own scale rather than being overwritten by the fine
// one running after it.
let mut clarity_only = AdjustPass::new(&ctx);
let coarse = row(
&render(&mut clarity_only, &graph_with(CLARITY, 80.0), &source, SIZE),
SIZE,
SIZE / 2,
);
assert!(
both[edge] >= coarse[edge],
"adding texture undid clarity: {} against {}",
both[edge],
coarse[edge]
);
}
#[test]
fn texture_contributes_nothing_where_its_scale_does_not_exist() {
// Unlike the acutance family this is not an approximation being hidden. A
// two-pixel surface structure is not present in a 128-pixel rendering of
// the frame, so the honest answer is no pass at all — and clarity, a
// hundred times wider, still runs, which is what a thumbnail should show.
let Some(ctx) = ctx() else { return };
let source = step_edge(&ctx, 512, [1.0, 1.0, 1.0]);
let mut graph = graph_with(TEXTURE, 100.0);
// Asserted on the composed chain rather than on a dispatch counter,
// because texture *alone* at this size is a configuration the stage as a
// whole cannot currently render, and that is a gap in the seam rather than
// in this operation.
//
// `compose_full` decides whether the fused pass should hand on linear
// working values from `is_active()`, which has no `RenderScale` to consult;
// `compose_detail` decides what to dispatch from the kernel it can actually
// draw at this scale. Almost always the two agree. They disagree exactly
// when a detail operation is active and its kernel rounds away, and then
// `render_detailed` finds an empty chain, falls through to `render_masked`,
// and is rejected for handing a linear-working shader to the plain path.
//
// Pre-existing, and not something clarity and texture introduce:
// `DetailStage::passes` documents the empty return as *the honest answer
// for an acutance operation on a heavy proxy*, so capture sharpening and
// noise reduction reach it by the same road. Fixing it means composing
// both halves of a render together, so the fused half can know whether a
// detail half survived the scale — a change at the composition boundary,
// not in this file.
let scale = graph.render_scale(source.size(), (128, 128));
assert!(
graph.compose_detail_for(scale, ColourSpace::Srgb).is_empty(),
"texture claimed a kernel it cannot draw"
);
// With clarity on as well the edit is renderable again, and the dispatch
// count says what the assertion above says: two passes, not four. Texture
// is active, and contributes nothing.
graph.set_param(CLARITY, AMOUNT, 100.0);
let mut pass = AdjustPass::new(&ctx);
render(&mut pass, &graph, &source, 128);
assert_eq!(
pass.detail_dispatches(),
2,
"clarity survives a thumbnail, and texture added nothing beside it"
);
// And texture comes back, exactly, as soon as the view is large enough to
// hold it — no separate path, no fade, just the kernel resolving again.
let mut zoomed = AdjustPass::new(&ctx);
render(&mut zoomed, &graph_with(TEXTURE, 100.0), &source, 1024);
assert_eq!(zoomed.detail_dispatches(), 2);
}