Merge branch 'worktree-agent-a22a049c461818dbe' into integration

# Conflicts:
#	core/dr-pipeline/tests/mask_sidecar.rs
This commit is contained in:
2026-08-22 13:23:34 +02:00
14 changed files with 2352 additions and 196 deletions
+19 -3
View File
@@ -43,7 +43,9 @@ struct MaskParams {
// `falloff_code` on the Rust side.
falloff: u32,
// Geometry, in normalised output coordinates. Meaning depends on `mode`.
// Geometry. Meaning depends on `mode`. The centre is in normalised 0..1
// coordinates; every distance below it is in the isotropic frame units
// `frame_delta` establishes.
centre: vec2<f32>,
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
axis: vec2<f32>,
@@ -128,9 +130,23 @@ fn region_mask(px: vec2<i32>) -> f32 {
return total / n;
}
// Offset from a gradient's centre, in the frame's own **isotropic** units:
// y spans 0..1 and x spans 0..aspect, so a step of the same length means the
// same distance whichever way it points.
//
// Without this the geometry lives in raw 0..1, where one axis is compressed
// against the other by the aspect ratio — so a 45° ramp is not at 45° on
// anything but a square frame, and a radial with equal radii draws an ellipse.
// Both faults are invisible in the stored numbers and obvious the moment a
// handle is dragged on a photograph, which is what this exists for.
fn frame_delta(uv: vec2<f32>) -> vec2<f32> {
let aspect = vec2<f32>(f32(p.width) / f32(max(p.height, 1u)), 1.0);
return (uv - p.centre) * aspect;
}
fn linear_mask(uv: vec2<f32>) -> f32 {
// Signed distance along the ramp direction, from the centre.
let d = dot(uv - p.centre, p.axis);
let d = dot(frame_delta(uv), p.axis);
if (p.softness <= 0.0) {
return select(0.0, 1.0, d >= 0.0);
}
@@ -140,7 +156,7 @@ fn linear_mask(uv: vec2<f32>) -> f32 {
fn radial_mask(uv: vec2<f32>) -> f32 {
let ca = cos(-p.angle);
let sa = sin(-p.angle);
let d = uv - p.centre;
let d = frame_delta(uv);
// Into the ellipse's own frame, then normalised by its semi-axes so the
// problem becomes a unit circle.
let local = vec2<f32>(d.x * ca - d.y * sa, d.x * sa + d.y * ca);
+132
View File
@@ -189,3 +189,135 @@ fn the_mask_covers_the_same_fraction_at_every_size() {
"and that share is the left half: {small:.3}"
);
}
// ---------------------------------------------------------------------------
// Gradient geometry
//
// The mask is rasterised over the source frame, whose two axes are not the
// same length. A gradient measured in raw 0..1 fractions therefore means a
// different distance horizontally than vertically — so a circle comes out an
// ellipse and an angle is not the angle asked for. Neither shows in the stored
// numbers, and both are what a photographer is looking straight at while
// dragging a handle.
// ---------------------------------------------------------------------------
/// A flat grey frame at an arbitrary shape, brightened wherever `stack` covers.
fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u8> {
let data: Vec<u8> = (0..w * h).flat_map(|_| [128u8, 128, 128, 255]).collect();
let source = DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload");
let mut masks = MaskPass::new(ctx).expect("mask pass");
let array = masks.render(stack, None, None, w, h).expect("rasterise");
let shader = compose_full(&ops::chain(), &Framing::new(), ColourSpace::Srgb, stack);
let mut adjust = AdjustPass::new(ctx);
adjust
.render_masked(&source, &shader, w, h, Some(array))
.expect("render");
adjust.export_pixels().expect("readback").0
}
fn brightened(pixels: &[u8], w: u32, x: u32, y: u32) -> bool {
pixels[((y * w + x) * 4) as usize] > 160
}
fn gradient(source: MaskSource) -> MaskStack {
let mut stack = MaskStack::new();
let mut layer = MaskLayer::new("g1", source);
layer.set_param("exposure", ParamId("exposure"), 2.0);
stack.push(layer);
stack
}
/// A radial with equal radii must be round on the screen, not on the numbers.
#[test]
fn a_radial_with_equal_radii_is_a_circle_on_a_wide_frame() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
// 3:2. On a square frame this test cannot fail, which is why it is not one.
const W: u32 = 96;
const H: u32 = 64;
// 0.3 of the frame's height. In pixels that is 19 either way — the x axis
// spans 0..1.5 in the same units, so the fraction is smaller and the
// distance is the same.
let pixels = render_gradient(
&ctx,
&gradient(MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.3, 0.3),
angle: 0.0,
// Hard, so "covered" is a question with an answer rather than a
// ramp to pick a threshold out of.
feather: 0.0,
}),
W,
H,
);
let (cx, cy) = (W / 2, H / 2);
assert!(brightened(&pixels, W, cx, cy), "the centre must be covered");
for (dx, dy, what) in [(15u32, 0u32, "right"), (0, 15, "down")] {
assert!(
brightened(&pixels, W, cx + dx, cy + dy),
"15px {what} of centre is inside a 19px radius"
);
}
for (dx, dy, what) in [(24u32, 0u32, "right"), (0, 24, "down")] {
assert!(
!brightened(&pixels, W, cx + dx, cy + dy),
"24px {what} of centre is outside it — before the aspect \
correction the horizontal reach was 28px and this passed only \
downwards"
);
}
}
/// And a ramp at 45° must be at 45° where the photographer sees it.
#[test]
fn a_diagonal_ramp_runs_at_the_angle_it_was_given() {
let Some(ctx) = ctx() else {
eprintln!("no adapter; skipping");
return;
};
const W: u32 = 96;
const H: u32 = 64;
let pixels = render_gradient(
&ctx,
&gradient(MaskSource::Linear {
centre: (0.5, 0.5),
angle: std::f32::consts::FRAC_PI_4,
width: 0.0,
}),
W,
H,
);
// Coverage increases along (cos 45°, sin 45°), so the half-way line runs
// from lower-left to upper-right through the centre: equal steps right and
// down stay on the covered side, and the two sides of it disagree.
let (cx, cy) = (W / 2, H / 2);
assert!(
brightened(&pixels, W, cx + 16, cy + 16),
"down and to the right of the line is inside"
);
assert!(
!brightened(&pixels, W, cx - 16, cy - 16),
"up and to the left of it is outside"
);
// The diagonal itself, sampled a few pixels either side. Without the
// aspect correction the line comes out at 34 degrees and both of these
// land on the same side of it.
assert!(
brightened(&pixels, W, cx + 18, cy - 14),
"just below the 45 degree line is inside"
);
assert!(
!brightened(&pixels, W, cx + 14, cy - 18),
"just above it is not"
);
}
+240 -2
View File
@@ -41,8 +41,10 @@
use std::f32::consts::PI;
use std::fmt::Write as _;
use crate::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale, Unit,
WidgetDemand, WidgetKind,};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation, Scale,
Unit, WidgetDemand, WidgetKind,
};
use crate::operation::Affects;
pub const ID: OpId = OpId("framing");
@@ -597,6 +599,105 @@ impl Framing {
.normalised()
}
/// TRACES: FR-DEV-3
/// Where an output point comes from in the source, both in normalised
/// `0..1` coordinates.
///
/// **This is [`Self::wgsl_prologue`] evaluated on the CPU**, for the one
/// caller that cannot run the shader: an interface hit-testing a control
/// drawn *on the photograph*. A gradient's handles are stored in source
/// coordinates and dragged in output ones, and the two are separated by
/// the crop, the zoom, the pan, the straightening and the turns — so a
/// handle that mapped through anything less would drift off the mask the
/// moment the view moved, which is exactly the fault masks are rasterised
/// in source space to avoid.
///
/// The two must agree step for step. They are kept together in this file,
/// and `the_cpu_map_matches_the_prologue_step_for_step` below pins the
/// correspondence so a change to one that is not made to the other fails
/// rather than showing up as a mask that is subtly wrong only when
/// straightened.
pub fn source_at(&self, out: (f32, f32), src_w: u32, src_h: u32) -> (f32, f32) {
let (ax, fx) = self.aspects(src_w, src_h);
let rect = self.visible_rect();
// Into the crop rect, then into the framed image's own centred space.
let uv = (rect.x + out.0 * rect.width, rect.y + out.1 * rect.height);
let mut p = ((uv.0 - 0.5) * fx, uv.1 - 0.5);
if self.angle != 0.0 {
let rad = self.angle * PI / 180.0;
let (s, c) = (rad.sin(), rad.cos());
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
}
let (turns, flip_h, flip_v) = self.effective();
p = match turns {
1 => (p.1 * ax, -p.0 / fx),
2 => (-p.0, -p.1),
3 => (-p.1 * ax, p.0 / fx),
_ => p,
};
if flip_h {
p.0 = -p.0;
}
if flip_v {
p.1 = -p.1;
}
(p.0 / ax + 0.5, p.1 + 0.5)
}
/// Where a source point lands on the output — [`Self::source_at`] run
/// backwards.
///
/// Outside `0..1` when the point is cropped away or panned off screen,
/// which is the honest answer: the caller draws a handle there and clips
/// it, rather than being handed a clamped position that claims the mask is
/// somewhere it is not.
pub fn output_at(&self, src: (f32, f32), src_w: u32, src_h: u32) -> (f32, f32) {
let (ax, fx) = self.aspects(src_w, src_h);
let mut p = ((src.0 - 0.5) * ax, src.1 - 0.5);
let (turns, flip_h, flip_v) = self.effective();
if flip_v {
p.1 = -p.1;
}
if flip_h {
p.0 = -p.0;
}
p = match turns {
1 => (-p.1 * fx, p.0 / ax),
2 => (-p.0, -p.1),
3 => (p.1 * fx, -p.0 / ax),
_ => p,
};
if self.angle != 0.0 {
let rad = -self.angle * PI / 180.0;
let (s, c) = (rad.sin(), rad.cos());
p = (p.0 * c - p.1 * s, p.0 * s + p.1 * c);
}
let uv = (p.0 / fx + 0.5, p.1 + 0.5);
let rect = self.visible_rect();
(
(uv.0 - rect.x) / rect.width.max(1e-6),
(uv.1 - rect.y) / rect.height.max(1e-6),
)
}
/// The x components of `aspect` and `frame_aspect`, whose y is always 1.
///
/// The pair the prologue puts in scope, and the distinction that makes a
/// quarter turn exact: `aspect` measures the source, `frame_aspect`
/// measures the frame the user is looking at, and a turn is where the two
/// meet.
fn aspects(&self, src_w: u32, src_h: u32) -> (f32, f32) {
let ax = src_w.max(1) as f32 / src_h.max(1) as f32;
(ax, if self.swaps_axes() { 1.0 / ax } else { ax })
}
/// Uniform values the generated prologue reads.
///
/// A fixed-size block in a fixed slot, like the camera matrix: the
@@ -1591,4 +1692,141 @@ mod tests {
}
}
}
// ---- the CPU coordinate map (source_at / output_at) -------------------
//
// These matter because the map has no other check on it. The shader's
// version is verified by the picture looking right; this one is read by
// hit-testing, where being wrong means a handle that grabs nothing and
// nothing on screen says why.
/// A 3:2 frame. Square would hide every aspect fault in here.
const SRC: (u32, u32) = (600, 400);
fn close(a: (f32, f32), b: (f32, f32), what: &str) {
assert!(
(a.0 - b.0).abs() < 1e-4 && (a.1 - b.1).abs() < 1e-4,
"{what}: {a:?} != {b:?}"
);
}
#[test]
fn an_unedited_frame_maps_an_output_point_to_itself() {
// The neutral prologue is `uv_src = uv`, and a map that quietly
// introduced an aspect factor here would put every mask a little off
// on every unedited photograph — the case that is never looked at
// twice.
let f = Framing::new();
for out in [(0.0, 0.0), (0.5, 0.5), (0.25, 0.8), (1.0, 1.0)] {
close(f.source_at(out, SRC.0, SRC.1), out, "neutral");
}
}
#[test]
fn the_map_round_trips_through_every_transform_at_once() {
// Handles are drawn with `output_at` and dragged with `source_at`, so
// a discrepancy between them is a handle that jumps away from the
// pointer on the first press. Every stage is on, because the faults
// that survive are the ones only a composition exposes — an aspect
// applied on one leg and not the other cancels under a bare rotation.
for turns in 0..4 {
let mut f = Framing::new();
f.set_crop(CropRect {
x: 0.1,
y: 0.2,
width: 0.6,
height: 0.5,
});
f.set_view(CropRect {
x: 0.3,
y: 0.25,
width: 0.4,
height: 0.4,
});
f.set_param(ANGLE, -7.5);
f.rotate_quarters(turns);
f.set_param(FLIP_H, 1.0);
f.set_param(FLIP_V, 1.0);
for out in [(0.0, 0.0), (0.5, 0.5), (0.2, 0.9), (0.95, 0.05)] {
let src = f.source_at(out, SRC.0, SRC.1);
close(f.output_at(src, SRC.0, SRC.1), out, "round trip");
}
}
}
#[test]
fn a_stored_orientation_is_part_of_the_map() {
// The baseline reaches the prologue through `effective`, so it has to
// reach this the same way. A portrait frame the camera stored sideways
// is the common case, and a map that ignored the tag would place every
// handle on a photograph that is not the one on screen.
let mut f = Framing::new();
f.set_baseline(dr_types::Orientation {
quarter_turns: 1,
flip_h: false,
flip_v: false,
});
// The output's top-left comes from the source's bottom-left under a
// clockwise quarter turn.
close(f.source_at((0.0, 0.0), SRC.0, SRC.1), (0.0, 1.0), "turned");
close(
f.output_at((0.0, 1.0), SRC.0, SRC.1),
(0.0, 0.0),
"turned back",
);
}
#[test]
fn zooming_in_narrows_what_an_output_point_reaches() {
// The property the handles depend on: the same place on screen is a
// *different* source point once the view moves, so a handle drawn from
// stored geometry has to be re-placed on every frame of a pan. If this
// were independent of the view the handles would sit still while the
// photograph slid under them.
let mut f = Framing::new();
let wide = f.source_at((0.25, 0.25), SRC.0, SRC.1);
f.set_view(CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
let close_in = f.source_at((0.25, 0.25), SRC.0, SRC.1);
assert!(close_in.0 > wide.0 && close_in.1 > wide.1, "{close_in:?}");
close(close_in, (0.375, 0.375), "zoomed");
}
#[test]
fn the_cpu_map_matches_the_prologue_step_for_step() {
// The two are the same function written twice, and nothing but this
// stops them drifting apart. It checks the *shape* — that the
// permutation the prologue emits for each turn is the one implemented
// above — because the alternative is running WGSL in a unit test.
let mut f = Framing::new();
f.rotate_quarters(1);
assert!(
f.wgsl_prologue()
.contains("p = vec2<f32>(p.y * aspect.x, -p.x / frame_aspect.x);"),
"the one-turn permutation moved; `source_at` must move with it"
);
let mut f = Framing::new();
f.rotate_quarters(3);
assert!(
f.wgsl_prologue()
.contains("p = vec2<f32>(-p.y * aspect.x, p.x / frame_aspect.x);"),
"the three-turn permutation moved; `source_at` must move with it"
);
// And the sampler's last step, which lives in `operation.rs` and is
// the half of the map this file does not emit.
assert!(
crate::operation::sample_source(false).contains("p / aspect + vec2<f32>(0.5)"),
"the sampler's return to texture coordinates moved"
);
}
}
+27 -8
View File
@@ -533,25 +533,44 @@ pub enum MaskSource {
/// A linear gradient — the graduated-filter mask.
///
/// Geometry is in **normalised output coordinates**, so it survives a crop
/// or an export at another size. Storing pixels would make a mask that
/// silently moves when the frame changes.
/// Geometry is in **normalised source coordinates**, so it survives a crop,
/// a zoom or an export at another size: the mask is rasterised in source
/// space and sampled through the framing map, exactly as a subject mask is.
/// Storing pixels would make a mask that silently moves when the frame
/// changes.
///
/// # Two spaces, and why both are here
///
/// The **centre** is a point, so it is a plain `0.0..=1.0` fraction of each
/// axis — the same coordinates a click carries.
///
/// The **angle and the width are measurements**, and a fraction of the
/// width is not the same length as a fraction of the height on any frame
/// that is not square. So they are in the frame's *isotropic* units: y
/// spans `0..1` and x spans `0..aspect`, which is what makes 45° actually
/// 45° and a circle actually round. `frame_delta` in `mask.wgsl` is the
/// one place that conversion happens, and it must stay the only one.
Linear {
/// Midpoint of the ramp, `0.0..=1.0` in each axis.
centre: (f32, f32),
/// Radians, measured from the +x axis.
/// Radians, measured clockwise from the +x axis in frame units.
/// Coverage increases in this direction.
angle: f32,
/// Distance from full effect to none, in normalised units. Zero is a
/// hard edge.
/// Distance from full effect to none, in frame units. Zero is a hard
/// edge.
width: f32,
},
/// A radial gradient — the classic vignette-shaped local adjustment.
///
/// The same two spaces as [`Self::Linear`]: a `0..1` centre, and semi-axes
/// measured in the frame's isotropic units so equal radii draw a circle.
Radial {
centre: (f32, f32),
/// Semi-axes, normalised. Two of them, because a face is an ellipse
/// and forcing a circle makes the user compensate with a crop.
/// Semi-axes, in frame units. Two of them, because a face is an
/// ellipse and forcing a circle makes the user compensate with a crop.
radii: (f32, f32),
/// Rotation of the ellipse, radians.
angle: f32,
/// Fraction of the radius over which the edge falls off.
feather: f32,
+1 -1
View File
@@ -562,7 +562,7 @@ fn encode_output(c: vec3<f32>) -> vec3<f32> {{
/// Split out because it is the join between the coordinate stage and the
/// colour stage, and because the choice it makes — an exact integer load, or
/// a filtered sample — is the one thing the free-angle case changes.
fn sample_source(interpolate: bool) -> &'static str {
pub(crate) fn sample_source(interpolate: bool) -> &'static str {
if interpolate {
" // Back to texture coordinates.
let uv_src = p / aspect + vec2<f32>(0.5);
+3
View File
@@ -568,6 +568,9 @@ fn show_a_sidecar() {
"m2",
MaskSource::Linear {
centre: (0.5, 0.25),
// The constant rather than four digits of it: clippy rejects the
// literal, and a quarter turn written as a number is a quarter
// turn nobody can see at a glance.
angle: std::f32::consts::FRAC_PI_2,
width: 0.4,
},