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:
@@ -326,7 +326,7 @@ fn render_stack(
|
||||
// after the framing map — which is what makes one mask correct at every
|
||||
// output size, zoom and crop.
|
||||
let array = masks
|
||||
.render(stack, None, Some(&subjects), pw, ph)
|
||||
.render(stack, None, Some(&subjects), Some(source), pw, ph)
|
||||
.expect("rasterise masks");
|
||||
|
||||
let shader = compose_full(
|
||||
|
||||
+193
-12
@@ -21,6 +21,17 @@
|
||||
//! boxes, one draw each, compositing onto the slice with blend state — see the
|
||||
//! second half of `mask.wgsl`.
|
||||
//!
|
||||
//! # The photograph, bound as an input
|
||||
//!
|
||||
//! A range mask (FR-DEV-10) selects by what a pixel *is*, so this pass reads
|
||||
//! the demosaiced source as well as writing masks. It is bound for every draw
|
||||
//! and looked at by two modes; everything else gets a 1x1 placeholder, for the
|
||||
//! reason the label field below does — the bindings are fixed, and a second
|
||||
//! pipeline differing only in what it ignores costs more than a texel.
|
||||
//!
|
||||
//! Nothing is read back and nothing is rasterised on this side. What crosses
|
||||
//! into CPU memory for a range layer is five floats and a matrix.
|
||||
//!
|
||||
//! # The label field
|
||||
//!
|
||||
//! Region masks index a compacted label field uploaded once per segmentation.
|
||||
@@ -31,9 +42,10 @@
|
||||
//! same place and cadence the region adjacency graph is already built at.
|
||||
|
||||
use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{GpuContext, GpuError};
|
||||
use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
|
||||
/// Modes understood by `mask.wgsl`. Kept beside the shader's `switch`.
|
||||
const MODE_REGIONS: u32 = 0;
|
||||
@@ -43,6 +55,10 @@ const MODE_SUBJECT: u32 = 3;
|
||||
/// Brush layers go through their own entry points rather than the `switch`, so
|
||||
/// this is only ever read by a person looking at a captured frame.
|
||||
const MODE_BRUSH: u32 = 4;
|
||||
/// TRACES: FR-DEV-10
|
||||
const MODE_LUMINANCE: u32 = 5;
|
||||
/// TRACES: FR-DEV-10
|
||||
const MODE_COLOUR: u32 = 6;
|
||||
|
||||
/// Six vertices — two triangles — per stroke. See `vs_brush`.
|
||||
const VERTICES_PER_STROKE: u32 = 6;
|
||||
@@ -66,7 +82,19 @@ struct MaskParams {
|
||||
axis: [f32; 2],
|
||||
softness: f32,
|
||||
angle: f32,
|
||||
_pad1: [f32; 2],
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Source texels per mask texel, per axis. See `image_value` in the
|
||||
/// shader for why a range averages its footprint rather than sampling it.
|
||||
source_step: [f32; 2],
|
||||
|
||||
/// Camera RGB → linear sRGB, one row per `vec4` because that is the
|
||||
/// alignment a uniform gives a three-component vector anyway. Only a
|
||||
/// range mask reads them.
|
||||
cam_to_srgb: [[f32; 4]; 3],
|
||||
/// `rgb`: as-shot white balance. `w`: non-zero for a gamma-encoded source.
|
||||
/// The same packing the generated adjust shader uses, so the two agree by
|
||||
/// construction rather than by inspection.
|
||||
as_shot_wb: [f32; 4],
|
||||
}
|
||||
|
||||
/// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it.
|
||||
@@ -371,6 +399,14 @@ pub struct MaskPass {
|
||||
/// label slots even when rasterising a gradient. A placeholder is cheaper
|
||||
/// and far simpler than two pipelines differing only in what they ignore.
|
||||
placeholder: LabelField,
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Bound at the image slot for every mask that is not a range.
|
||||
///
|
||||
/// Never sampled by those modes, so its contents do not matter — but it is
|
||||
/// cleared rather than left undefined, because a placeholder whose value
|
||||
/// is arbitrary is one that makes a binding mistake look like a mask that
|
||||
/// nearly works.
|
||||
empty_image: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl MaskPass {
|
||||
@@ -405,6 +441,20 @@ impl MaskPass {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// The photograph, for a range mask. Unfilterable for the
|
||||
// same reason the field above is: every read is a
|
||||
// `textureLoad`, and this pipeline binds no sampler.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 6,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -515,6 +565,7 @@ impl MaskPass {
|
||||
}
|
||||
|
||||
let placeholder = LabelField::upload(ctx, &[0], 1, 1, 0)?;
|
||||
let empty_image = empty_image(ctx);
|
||||
// Everywhere outside, so a layer that somehow reaches this masks
|
||||
// nothing rather than everything.
|
||||
let empty_subject = SubjectMasks::upload(ctx, &[&[-1.0f32][..]], 1, 1)?;
|
||||
@@ -530,6 +581,7 @@ impl MaskPass {
|
||||
allocations: 0,
|
||||
placeholder,
|
||||
empty_subject,
|
||||
empty_image,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -538,11 +590,17 @@ impl MaskPass {
|
||||
/// `labels` may be `None` when no layer is a region mask; a region layer
|
||||
/// without one is skipped rather than drawn wrong, since a mask that
|
||||
/// silently covers the whole frame would apply an edit everywhere.
|
||||
///
|
||||
/// `source` is the photograph a range layer measures (FR-DEV-10), and it
|
||||
/// is skipped on the same rule for the same reason: without it the shader
|
||||
/// would read a blank placeholder, and a band that happens to contain
|
||||
/// black would then cover the whole frame.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
stack: &MaskStack,
|
||||
labels: Option<&LabelField>,
|
||||
subjects: Option<&SubjectMasks>,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<&MaskArray, GpuError> {
|
||||
@@ -597,7 +655,24 @@ impl MaskPass {
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
|
||||
let params = self.params(layer, field, width, height);
|
||||
// TRACES: FR-DEV-10
|
||||
// A range layer with no photograph bound is skipped rather than
|
||||
// drawn against the placeholder, on exactly the rule the two
|
||||
// cases above follow: an absent mask that defaults to "everything"
|
||||
// takes a local adjustment global, which is a far quieter failure
|
||||
// than a layer that visibly did not render.
|
||||
let image = match (&layer.source, source) {
|
||||
(s, None) if s.is_range() => {
|
||||
log::warn!(
|
||||
"mask layer {} selects a range with no image loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
(_, image) => image,
|
||||
};
|
||||
|
||||
let params = self.params(layer, field, image, width, height);
|
||||
match &layer.source {
|
||||
MaskSource::Brush { strokes } => {
|
||||
self.draw_brush(&mut encoder, slot as u32, ¶ms, strokes, width, height)
|
||||
@@ -611,6 +686,7 @@ impl MaskPass {
|
||||
field,
|
||||
&selected,
|
||||
subject,
|
||||
image,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -634,9 +710,34 @@ impl MaskPass {
|
||||
&self,
|
||||
layer: &dr_pipeline::mask::MaskLayer,
|
||||
field: &LabelField,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> MaskParams {
|
||||
// TRACES: FR-DEV-10
|
||||
// How much of the photograph one mask texel covers. One when there is
|
||||
// no image bound, which is a value nothing reads — the range modes are
|
||||
// the only readers and they are skipped in that case.
|
||||
let source_step = match source {
|
||||
Some(image) => {
|
||||
let (sw, sh) = image.size();
|
||||
[
|
||||
sw as f32 / width.max(1) as f32,
|
||||
sh as f32 / height.max(1) as f32,
|
||||
]
|
||||
}
|
||||
None => [1.0, 1.0],
|
||||
};
|
||||
// Row-major nine, widened to three `vec4`s. Identity where there is no
|
||||
// image, so a range that somehow reached the shader without one would
|
||||
// read camera values rather than nothing — the same defensive choice
|
||||
// the demosaicer makes for an uncalibrated body.
|
||||
let m = source.map_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], |i| {
|
||||
i.color_matrix()
|
||||
});
|
||||
let wb = source.map_or([1.0, 1.0, 1.0], |i| i.as_shot_wb());
|
||||
let non_linear = source.is_some_and(|i| i.is_non_linear());
|
||||
|
||||
let base = MaskParams {
|
||||
width,
|
||||
height,
|
||||
@@ -650,7 +751,13 @@ impl MaskPass {
|
||||
axis: [1.0, 0.0],
|
||||
softness: 0.0,
|
||||
angle: 0.0,
|
||||
_pad1: [0.0, 0.0],
|
||||
source_step,
|
||||
cam_to_srgb: [
|
||||
[m[0], m[1], m[2], 0.0],
|
||||
[m[3], m[4], m[5], 0.0],
|
||||
[m[6], m[7], m[8], 0.0],
|
||||
],
|
||||
as_shot_wb: [wb[0], wb[1], wb[2], if non_linear { 1.0 } else { 0.0 }],
|
||||
};
|
||||
|
||||
match &layer.source {
|
||||
@@ -719,6 +826,33 @@ impl MaskPass {
|
||||
mode: MODE_BRUSH,
|
||||
..base
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// A band, carried in the fields the gradients measure geometry
|
||||
// in. Reused rather than given their own, and it is not a
|
||||
// shortcut: `centre` and `axis` are two pairs of floats whose
|
||||
// meaning has always been the mode's to decide, and a range that
|
||||
// added four more would grow the uniform every other mask pays
|
||||
// for. What matters is that nothing here is a *coordinate* — a
|
||||
// range is not a function of position at all.
|
||||
MaskSource::Luminance { lo, hi, softness } => MaskParams {
|
||||
mode: MODE_LUMINANCE,
|
||||
axis: [*lo, *hi],
|
||||
softness: *softness,
|
||||
..base
|
||||
},
|
||||
MaskSource::Colour {
|
||||
hue,
|
||||
hue_width,
|
||||
chroma_lo,
|
||||
chroma_hi,
|
||||
softness,
|
||||
} => MaskParams {
|
||||
mode: MODE_COLOUR,
|
||||
centre: [*hue, *hue_width],
|
||||
axis: [*chroma_lo, *chroma_hi],
|
||||
softness: *softness,
|
||||
..base
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,6 +1021,7 @@ impl MaskPass {
|
||||
field: &LabelField,
|
||||
selected: &wgpu::Buffer,
|
||||
subject: (&SubjectMasks, usize),
|
||||
source: Option<&DemosaicedImage>,
|
||||
) {
|
||||
let params_buf = self
|
||||
.ctx
|
||||
@@ -924,6 +1059,13 @@ impl MaskPass {
|
||||
}),
|
||||
),
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
source.map_or(&self.empty_image, |i| i.view()),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1005,6 +1147,38 @@ impl MaskPass {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A single black texel, bound at the image slot for a mask that is not a
|
||||
/// range.
|
||||
///
|
||||
/// Written rather than merely allocated. Undefined contents would be read by
|
||||
/// nothing today, but a binding mistake in a range mask would then produce
|
||||
/// whatever the driver left in memory — a mask that flickers between builds
|
||||
/// and machines, which is the hardest shape of bug this pass could have.
|
||||
fn empty_image(ctx: &GpuContext) -> wgpu::TextureView {
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("mask-empty-image"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DemosaicedImage::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
// Four half-floats of zero. Rgba16Float, so eight bytes.
|
||||
&[0u8; 8],
|
||||
);
|
||||
texture.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
}
|
||||
|
||||
/// The shorter edge of the space the mask is rasterised in.
|
||||
///
|
||||
/// Feather and morphology are stored as fractions of it, so the same edit is
|
||||
@@ -1131,7 +1305,7 @@ mod tests {
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass
|
||||
.render(&stack, Some(&field), None, w, h)
|
||||
.render(&stack, Some(&field), None, None, w, h)
|
||||
.expect("render");
|
||||
assert_eq!(array.size(), (w, h));
|
||||
assert_eq!(array.layers(), 1);
|
||||
@@ -1153,7 +1327,7 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
assert!(pass.render(&stack, None, None, 8, 8).is_ok());
|
||||
assert!(pass.render(&stack, None, None, None, 8, 8).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1176,7 +1350,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass.render(&stack, None, None, 16, 16).expect("render");
|
||||
let array = pass
|
||||
.render(&stack, None, None, None, 16, 16)
|
||||
.expect("render");
|
||||
assert_eq!(array.layers(), 2, "one slice per active layer");
|
||||
}
|
||||
|
||||
@@ -1208,7 +1384,9 @@ mod tests {
|
||||
stack.push(painted(&[(false, 0.1, vec![(0.2, 0.2), (0.8, 0.8)])]));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
let array = pass
|
||||
.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(array.layers(), 1);
|
||||
}
|
||||
|
||||
@@ -1258,7 +1436,7 @@ mod tests {
|
||||
};
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass
|
||||
.render(&MaskStack::new(), None, None, 8, 8)
|
||||
.render(&MaskStack::new(), None, None, None, 8, 8)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
array.layers(),
|
||||
@@ -1281,17 +1459,20 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
pass.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(pass.allocations(), 1);
|
||||
|
||||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
pass.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
pass.allocations(),
|
||||
1,
|
||||
"same size and layer count should not reallocate"
|
||||
);
|
||||
|
||||
pass.render(&stack, None, None, 64, 64).expect("render");
|
||||
pass.render(&stack, None, None, None, 64, 64)
|
||||
.expect("render");
|
||||
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ struct MaskParams {
|
||||
label_width: u32,
|
||||
label_height: u32,
|
||||
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush,
|
||||
// 5 = luminance range, 6 = colour range.
|
||||
//
|
||||
// A brush does not read this — it has its own entry points, because it is
|
||||
// the one mask that is not a function of the whole frame — but it is set
|
||||
@@ -50,10 +51,33 @@ struct MaskParams {
|
||||
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
|
||||
axis: vec2<f32>,
|
||||
// Linear: ramp width. Radial: edge falloff as a fraction of the radius.
|
||||
// A range: the fade at each edge of its band, in the band's own units.
|
||||
softness: f32,
|
||||
// Radial only: rotation of the ellipse.
|
||||
angle: f32,
|
||||
_pad1: vec2<f32>,
|
||||
|
||||
// TRACES: FR-DEV-10
|
||||
// How many source texels one mask texel spans, per axis.
|
||||
//
|
||||
// The mask array is rasterised at a proxy size and the photograph is not,
|
||||
// so one texel here covers several there. A range mask is a function of
|
||||
// pixel *values*, and point-sampling one source texel in four would make
|
||||
// its edge follow the sensor's noise wherever the picture has fine
|
||||
// texture — speckle that is then a mask, and therefore visible in the
|
||||
// adjustment. Averaging the footprint is what makes the band land on the
|
||||
// tone the area actually is.
|
||||
source_step: vec2<f32>,
|
||||
|
||||
// Camera RGB → linear sRGB, one row each. Only a range reads these: it is
|
||||
// the one mask that looks at the photograph, and a hue is the body's own
|
||||
// primaries until this matrix has been applied — so the same stored arc
|
||||
// would select a different set of colours on every make of sensor.
|
||||
cam_to_srgb_0: vec4<f32>,
|
||||
cam_to_srgb_1: vec4<f32>,
|
||||
cam_to_srgb_2: vec4<f32>,
|
||||
// rgb: as-shot white balance. w: non-zero when the source arrived
|
||||
// gamma-encoded rather than linear.
|
||||
as_shot_wb: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: MaskParams;
|
||||
@@ -74,6 +98,19 @@ struct MaskParams {
|
||||
// shrinking and feathering free: each is arithmetic on this, so a slider moves
|
||||
// a uniform instead of rebuilding a mask.
|
||||
@group(0) @binding(3) var subject: texture_2d<f32>;
|
||||
// TRACES: FR-DEV-10
|
||||
// The photograph itself, as the demosaicer left it: camera RGB, unbalanced,
|
||||
// with no edit applied. A 1x1 placeholder for every mask that is a shape,
|
||||
// because the bindings are fixed and a second pipeline differing only in what
|
||||
// it ignores would cost more than one texel.
|
||||
//
|
||||
// **The unedited image, and that is the design rather than an accident of
|
||||
// pass order.** A band over the *edited* result would move as the edit was
|
||||
// made: raising the highlights would change which pixels counted as
|
||||
// highlights, so the slider would chase its own mask. Measuring what the
|
||||
// camera recorded means the selection stays where the photographer put it
|
||||
// while they work on it.
|
||||
@group(0) @binding(6) var image: texture_2d<f32>;
|
||||
|
||||
// A full-screen triangle rather than a quad: three vertices instead of six,
|
||||
// no shared edge for the rasteriser to crack along, and no vertex buffer.
|
||||
@@ -221,6 +258,177 @@ fn subject_mask(uv: vec2<f32>) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Range masks (FR-DEV-10)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The masks that select by what a pixel *is* rather than by where it sits.
|
||||
// Nothing below reads `frame_delta`, and that absence is the point: a range is
|
||||
// not a function of position, so it cannot be stretched by an aspect ratio,
|
||||
// cannot drift under a crop, and comes out the same at a proxy size and at an
|
||||
// export because the only thing it depends on is the photograph's own values.
|
||||
//
|
||||
// The band arrives entirely in the fields the gradients use — `axis` is the
|
||||
// pair of bounds, `centre` is a colour range's arc, `softness` is the fade —
|
||||
// so a range costs nothing in the uniform beyond the image transform above.
|
||||
|
||||
// Display-encoded sRGB back to linear.
|
||||
//
|
||||
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
|
||||
// where the demosaicer's are linear. The same undoing the generated adjust
|
||||
// shader does, at the same point and for the same reason: a band over
|
||||
// brightness is meaningless if two sources disagree about what a value means.
|
||||
fn decode_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
let lo = c / 12.92;
|
||||
let hi = pow((max(c, vec3<f32>(0.04045)) + 0.055) / 1.055, vec3<f32>(2.4));
|
||||
return select(hi, lo, c <= vec3<f32>(0.04045));
|
||||
}
|
||||
|
||||
// One source texel, as linear sRGB.
|
||||
//
|
||||
// This is the prologue of the generated adjust shader, repeated: decode,
|
||||
// balance, pull a clipped pixel back to neutral, then the camera matrix. It is
|
||||
// repeated rather than shared because the composer emits WGSL for the *edit*
|
||||
// and this pass is not one — but it must agree with it, since a range mask
|
||||
// exists to select the values the layer's own adjustments will then see.
|
||||
//
|
||||
// The highlight desaturation is the part that looks skippable and is not. A
|
||||
// fully clipped photosite arrives as (1,1,1), carrying no colour at all; the
|
||||
// as-shot multipliers are far from neutral, so balancing it and passing it
|
||||
// through the matrix produces a strong magenta. A colour range would then
|
||||
// select every blown sky as if the photographer had asked for magenta.
|
||||
fn source_texel(px: vec2<i32>) -> vec3<f32> {
|
||||
var c = textureLoad(image, px, 0).rgb;
|
||||
if (p.as_shot_wb.w > 0.5) {
|
||||
c = decode_srgb(c);
|
||||
}
|
||||
|
||||
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
|
||||
c = c * p.as_shot_wb.rgb;
|
||||
if (clipped > 0.0) {
|
||||
c = mix(c, vec3<f32>(max(c.r, max(c.g, c.b))), clipped);
|
||||
}
|
||||
|
||||
return vec3<f32>(
|
||||
dot(p.cam_to_srgb_0.rgb, c),
|
||||
dot(p.cam_to_srgb_1.rgb, c),
|
||||
dot(p.cam_to_srgb_2.rgb, c),
|
||||
);
|
||||
}
|
||||
|
||||
// The most taps one mask texel averages, per axis.
|
||||
//
|
||||
// A cap rather than the true footprint. At a 1600 px proxy over a 24 MP frame
|
||||
// the ratio is under four, so this is the whole footprint for every ordinary
|
||||
// photograph; past it the taps stride across the footprint instead of
|
||||
// covering it, which is a sample of the area rather than its mean. That is the
|
||||
// right way to run out of budget here — the estimate gets noisier, it does not
|
||||
// start measuring somewhere else.
|
||||
const MAX_SOURCE_TAPS: i32 = 4;
|
||||
|
||||
// The photograph's value under one mask texel, in linear sRGB.
|
||||
fn image_value(px: vec2<i32>) -> vec3<f32> {
|
||||
let dims = vec2<i32>(textureDimensions(image));
|
||||
let last = dims - vec2<i32>(1);
|
||||
|
||||
// The footprint's top-left corner in source texels. Not a centre plus a
|
||||
// radius: the mask texel is a *box* over the source, and sampling
|
||||
// symmetrically about its centre would weight the middle of every
|
||||
// footprint twice at odd tap counts.
|
||||
let origin = vec2<f32>(px) * p.source_step;
|
||||
let taps = clamp(vec2<i32>(ceil(p.source_step)), vec2<i32>(1), vec2<i32>(MAX_SOURCE_TAPS));
|
||||
// `stride`, not `step`: WGSL has a builtin of that name, and a local that
|
||||
// shadows one is legal and unreadable in the same breath.
|
||||
let stride = p.source_step / vec2<f32>(taps);
|
||||
|
||||
var total = vec3<f32>(0.0);
|
||||
for (var y = 0; y < taps.y; y = y + 1) {
|
||||
for (var x = 0; x < taps.x; x = x + 1) {
|
||||
let at = origin + (vec2<f32>(f32(x), f32(y)) + vec2<f32>(0.5)) * stride;
|
||||
total = total + source_texel(clamp(vec2<i32>(at), vec2<i32>(0), last));
|
||||
}
|
||||
}
|
||||
return total / f32(taps.x * taps.y);
|
||||
}
|
||||
|
||||
// A soft band: one inside, nothing outside, a smooth ramp across each edge.
|
||||
//
|
||||
// The `min` rather than a product of the two ramps. A band narrower than twice
|
||||
// its softness has no plateau, and multiplying the rising and falling ramps
|
||||
// would then peak well below one — so "select the highlights" would come out
|
||||
// at sixty per cent and the photographer would compensate with opacity,
|
||||
// against a mask that was quietly weaker than it said. `min` keeps the
|
||||
// plateau where there is one and degrades to a single peak where there is not.
|
||||
fn band(v: f32, lo: f32, hi: f32, soft: f32) -> f32 {
|
||||
if (soft <= 0.0) {
|
||||
return select(0.0, 1.0, v >= lo && v <= hi);
|
||||
}
|
||||
return min(smoothstep(lo - soft, lo, v), 1.0 - smoothstep(hi, hi + soft, v));
|
||||
}
|
||||
|
||||
fn luminance_mask(px: vec2<i32>) -> f32 {
|
||||
let y = dot(image_value(px), vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
// Onto the perceptual position `tone_position` in `ops/_helpers.yaml`
|
||||
// establishes, which is where the stored bounds are measured. Linear light
|
||||
// puts middle grey at 0.18, so a band stated in it would spend four fifths
|
||||
// of its travel inside the shadows.
|
||||
let t = clamp(pow(max(y, 0.0), 1.0 / 3.0), 0.0, 1.0);
|
||||
return band(t, p.axis.x, p.axis.y, p.softness);
|
||||
}
|
||||
|
||||
// Hue in turns, 0 at red and increasing through yellow.
|
||||
//
|
||||
// The plain six-sector definition. Zero for a neutral, which is a value the
|
||||
// caller must not act on — the chroma bound below is what keeps a colour range
|
||||
// away from the greys where this number is rounding noise.
|
||||
fn hue_of(c: vec3<f32>) -> f32 {
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
let d = hi - lo;
|
||||
if (d <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
var h = 0.0;
|
||||
if (hi == c.r) {
|
||||
h = (c.g - c.b) / d;
|
||||
} else if (hi == c.g) {
|
||||
h = (c.b - c.r) / d + 2.0;
|
||||
} else {
|
||||
h = (c.r - c.g) / d + 4.0;
|
||||
}
|
||||
return fract(h / 6.0);
|
||||
}
|
||||
|
||||
fn colour_mask(px: vec2<i32>) -> f32 {
|
||||
let c = max(image_value(px), vec3<f32>(0.0));
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
// The max-minus-min chroma `colour_saturation` uses, so the number the
|
||||
// band is stated in is the one the rest of the pipeline means by
|
||||
// 'colourfulness'.
|
||||
var chroma = 0.0;
|
||||
if (hi > 0.0) {
|
||||
chroma = (hi - lo) / hi;
|
||||
}
|
||||
|
||||
// Distance round the circle, so an arc centred near red reaches both ways
|
||||
// past zero. Written as a wrap rather than as two comparisons because red
|
||||
// is exactly where skin sits, and an arc that stopped at the seam would
|
||||
// select half of it.
|
||||
let d = abs(fract(hue_of(c) - p.centre.x + 0.5) - 0.5);
|
||||
var arc = 0.0;
|
||||
if (p.softness <= 0.0) {
|
||||
arc = select(0.0, 1.0, d <= p.centre.y);
|
||||
} else {
|
||||
arc = 1.0 - smoothstep(p.centre.y, p.centre.y + p.softness, d);
|
||||
}
|
||||
|
||||
// Both, not either: an arc alone selects a haze of noise everywhere the
|
||||
// picture is nearly grey, because a hue rounded out of three almost-equal
|
||||
// channels is still a hue.
|
||||
return min(arc, band(chroma, p.axis.x, p.axis.y, p.softness));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let px = vec2<i32>(i32(pos.x), i32(pos.y));
|
||||
@@ -235,6 +443,8 @@ fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
case 1u: { m = linear_mask(uv); }
|
||||
case 2u: { m = radial_mask(uv); }
|
||||
case 3u: { m = subject_mask(uv); }
|
||||
case 5u: { m = luminance_mask(px); }
|
||||
case 6u: { m = colour_mask(px); }
|
||||
default: { m = 0.0; }
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,9 @@ fn render_at(
|
||||
);
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks.render(stack, field, None, w, h).expect("rasterise");
|
||||
let array = masks
|
||||
.render(stack, field, None, None, w, h)
|
||||
.expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
|
||||
@@ -81,7 +81,7 @@ fn render_at(ctx: &GpuContext, stack: &MaskStack, out: u32) -> Vec<u8> {
|
||||
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(stack, None, Some(&subjects), PROXY, PROXY)
|
||||
.render(stack, None, Some(&subjects), None, PROXY, PROXY)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
@@ -224,7 +224,9 @@ fn render_gradient(ctx: &GpuContext, stack: &MaskStack, w: u32, h: u32) -> Vec<u
|
||||
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 array = masks
|
||||
.render(stack, None, None, None, w, h)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// TRACES: FR-DEV-10
|
||||
//! A range mask must select by the photograph's values, and by nothing else.
|
||||
//!
|
||||
//! Two properties, and both are silent when they break. A range mask that
|
||||
//! reads the wrong pixels still produces a plausible-looking selection, and one
|
||||
//! that depends on the size it was rasterised at looks right in the develop
|
||||
//! view and wrong only in the export — the one place nobody is watching.
|
||||
//!
|
||||
//! Everything here goes through the real pass. There is no CPU rasterisation of
|
||||
//! a mask to test against and there must not be (ARCH §5.4), so the mask is
|
||||
//! observed the only way it exists: through the adjustment it weights.
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, Framing};
|
||||
use dr_types::ColourSpace;
|
||||
|
||||
/// The source and the output are the same size, so a difference between two
|
||||
/// runs can only have come from the mask.
|
||||
const SIZE: u32 = 64;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A frame whose left half is one colour and right half another.
|
||||
///
|
||||
/// Deliberately not a ramp. A range mask's whole job is to divide the picture
|
||||
/// by value, so a source that is already divided by value makes the assertion
|
||||
/// "the mask found the half it was aimed at" rather than "the mask is roughly
|
||||
/// where it should be".
|
||||
fn split(ctx: &GpuContext, left: [u8; 3], right: [u8; 3]) -> DemosaicedImage {
|
||||
let data: Vec<u8> = (0..SIZE * SIZE)
|
||||
.flat_map(|i| {
|
||||
let c = if i % SIZE < SIZE / 2 { left } else { right };
|
||||
[c[0], c[1], c[2], 255]
|
||||
})
|
||||
.collect();
|
||||
DemosaicedImage::from_rgba8(ctx, &data, SIZE, SIZE).expect("upload")
|
||||
}
|
||||
|
||||
fn brightened(source: MaskSource) -> MaskStack {
|
||||
let mut stack = MaskStack::new();
|
||||
let mut layer = MaskLayer::new("m1", source);
|
||||
// Two stops, so "selected" and "not selected" are not a judgement call.
|
||||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||||
stack.push(layer);
|
||||
stack
|
||||
}
|
||||
|
||||
/// Render `stack` over `source`, with the mask rasterised at `raster`.
|
||||
///
|
||||
/// `raster` is a parameter because it is the thing that must not matter: the
|
||||
/// develop view and an export ask for the same mask at different sizes, and a
|
||||
/// range that answered differently at each would be a mask that changes when
|
||||
/// the photograph is exported.
|
||||
fn render(ctx: &GpuContext, source: &DemosaicedImage, stack: &MaskStack, raster: u32) -> Vec<u8> {
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(stack, None, None, Some(source), raster, raster)
|
||||
.expect("rasterise");
|
||||
|
||||
let shader = compose_full(
|
||||
&ops::chain(),
|
||||
&Framing::new(),
|
||||
ColourSpace::Srgb,
|
||||
stack,
|
||||
&SpotSet::new(),
|
||||
&[],
|
||||
);
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust
|
||||
.render_masked(source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
adjust.export_pixels().expect("readback").0
|
||||
}
|
||||
|
||||
fn red_at(pixels: &[u8], x: u32, y: u32) -> u8 {
|
||||
pixels[((y * SIZE + x) * 4) as usize]
|
||||
}
|
||||
|
||||
/// The centre of each half, away from the seam the mask's own softness
|
||||
/// straddles.
|
||||
fn halves(pixels: &[u8]) -> (u8, u8) {
|
||||
(
|
||||
red_at(pixels, SIZE / 4, SIZE / 2),
|
||||
red_at(pixels, SIZE * 3 / 4, SIZE / 2),
|
||||
)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
#[test]
|
||||
fn a_tone_band_brightens_only_the_half_inside_it() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// 200 lands near 0.83 on the perceptual scale and 30 near 0.24, so a band
|
||||
// over the upper half contains one and not the other with room to spare.
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (bright, dark) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
bright > 230,
|
||||
"the bright half is inside the band and should have been lifted, got {bright}"
|
||||
);
|
||||
assert!(
|
||||
dark < 45,
|
||||
"the dark half is outside the band and must be untouched, got {dark}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The property the develop view and the export path share. The mask array is
|
||||
/// rasterised at a proxy size in both, but nothing in this pass may *depend*
|
||||
/// on that size — a range is a function of the photograph's values, and those
|
||||
/// do not change when somebody asks for a bigger picture.
|
||||
#[test]
|
||||
fn a_tone_band_is_the_same_mask_at_any_raster_size() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::luminance_range(0.5, 1.0, 0.15));
|
||||
|
||||
// A quarter of the source and twice it: one mask texel averaging sixteen
|
||||
// source texels, and one source texel spread over four mask texels.
|
||||
let small = halves(&render(&ctx, &source, &stack, SIZE / 4));
|
||||
let large = halves(&render(&ctx, &source, &stack, SIZE * 2));
|
||||
|
||||
// A tolerance rather than equality: the two rasters land their edges on
|
||||
// different grids, and the assertion is that the *selection* is the same,
|
||||
// not that two resamplings of it are bit-identical.
|
||||
assert!(
|
||||
small.0.abs_diff(large.0) <= 4 && small.1.abs_diff(large.1) <= 4,
|
||||
"the same band selected differently at two raster sizes: {small:?} vs {large:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
#[test]
|
||||
fn a_colour_band_follows_hue_rather_than_brightness() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// Short of saturation on purpose. A fully clipped channel carries no
|
||||
// colour at all, and the pass pulls such a pixel back to neutral before
|
||||
// the band ever sees it — which is correct, and would make this test about
|
||||
// that instead.
|
||||
let source = split(&ctx, [200, 40, 40], [40, 40, 200]);
|
||||
// A narrow arc at red, above the chroma floor that keeps the greys out.
|
||||
let stack = brightened(MaskSource::colour_range(0.0, 0.05, 0.15, 1.0, 0.05));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (red, blue) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
red > 230,
|
||||
"the red half is inside the arc and should have been lifted, got {red}"
|
||||
);
|
||||
assert!(
|
||||
blue < 60,
|
||||
"the blue half is a third of the circle away and must be untouched, got {blue}"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The greys a hue arc would otherwise sweep up.
|
||||
///
|
||||
/// A nearly-neutral pixel still has a hue — three almost-equal channels round
|
||||
/// to one — so an arc without a chroma floor selects a haze of noise across
|
||||
/// every desaturated part of the picture. It is the failure that looks like the
|
||||
/// mask working badly rather than like a control that is missing.
|
||||
#[test]
|
||||
fn a_colour_band_ignores_the_greys() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
// Both halves neutral, at the two brightnesses the tone test uses, so the
|
||||
// only reason either could be selected is the hue arc reaching them.
|
||||
let source = split(&ctx, [200, 200, 200], [30, 30, 30]);
|
||||
let stack = brightened(MaskSource::colour_range(0.0, 0.5, 0.15, 1.0, 0.05));
|
||||
|
||||
let pixels = render(&ctx, &source, &stack, SIZE);
|
||||
let (light, dark) = halves(&pixels);
|
||||
|
||||
assert!(
|
||||
light < 215 && dark < 45,
|
||||
"a grey frame was selected by a colour mask: {light}, {dark}"
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,24 @@
|
||||
//! still merges per field. The raster sits beside it as a cache, takes no part
|
||||
//! in equality, and is thrown away rather than trusted when it does not fit.
|
||||
//! See [`crate::coverage`].
|
||||
//!
|
||||
//! # The masks that are not shapes
|
||||
//!
|
||||
//! Everything above selects by *where*: a region, an instance, a gradient's
|
||||
//! geometry, the path a finger took. [`MaskSource::Luminance`] and
|
||||
//! [`MaskSource::Colour`] select by *what a pixel is* — a band over the
|
||||
//! photograph's own brightness or its own colour, with the edges of the band
|
||||
//! soft (FR-DEV-10). That is what a luminosity mask has always been, and it is
|
||||
//! why a local edit made through one blends without a halo: the boundary
|
||||
//! follows the picture's values exactly, at whatever detail the picture has,
|
||||
//! rather than an outline drawn near them.
|
||||
//!
|
||||
//! They store the band and nothing else, for precisely the reason the region
|
||||
//! ids above are ids: five numbers diff, sync and merge per field under
|
||||
//! FR-NC-9, where the pixels they select would be a blob for a merge to
|
||||
//! arbitrate. And the pixels never need storing, because unlike a model's
|
||||
//! coverage they are reproducible by anything that can read the photograph —
|
||||
//! which is every path that renders one.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::sync::Arc;
|
||||
@@ -104,6 +122,27 @@ pub const MAX_STROKE_POINTS: usize = 256;
|
||||
/// work the user can see on screen must not vanish without being told.
|
||||
pub const MAX_LAYER_POINTS: usize = 4096;
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// How far a range mask fades in and out at each edge of its band.
|
||||
///
|
||||
/// In the band's own units — tone position for a luminance range, chroma or
|
||||
/// turns of hue for a colour one — because that is the only place a range
|
||||
/// mask has an edge. It has no boundary in the picture and therefore no
|
||||
/// distance from one, which is why [`MaskLayer::feather`] cannot reach it.
|
||||
///
|
||||
/// The default is generous on purpose. A hard band over a photograph's values
|
||||
/// finds the contour lines of the picture and draws them, and a tone contour
|
||||
/// crossing a smooth sky is the one artefact a luminosity mask exists to
|
||||
/// avoid. Softness is what turns the band from a threshold into a weighting.
|
||||
pub const DEFAULT_RANGE_SOFTNESS: f32 = 0.15;
|
||||
|
||||
/// The widest a hue arc may be, in turns.
|
||||
///
|
||||
/// Half a turn each way is the whole circle, so anything past this is not a
|
||||
/// selection — it is every colour, arrived at by a control the user thought
|
||||
/// was narrowing something.
|
||||
pub const MAX_HUE_WIDTH: f32 = 0.5;
|
||||
|
||||
/// Brush radius a new stroke starts at, as a fraction of the shorter edge.
|
||||
pub const DEFAULT_BRUSH_RADIUS: f32 = 0.05;
|
||||
/// Fraction of the radius that is fully covered before the edge falls away.
|
||||
@@ -148,6 +187,21 @@ fn snap(v: f32) -> f32 {
|
||||
(v * STROKE_GRID).round() / STROKE_GRID
|
||||
}
|
||||
|
||||
/// Two band edges, clamped to `0..=1` and put the right way round.
|
||||
///
|
||||
/// Swapped rather than collapsed to a point: dragging one handle past the
|
||||
/// other is how every range control in every editor is used to reverse a
|
||||
/// selection's ends, and the alternative — clamping the lower bound against
|
||||
/// the upper — makes the band stick at zero width and the drag stop dead.
|
||||
fn ordered(lo: f32, hi: f32) -> (f32, f32) {
|
||||
let (lo, hi) = (lo.clamp(0.0, 1.0), hi.clamp(0.0, 1.0));
|
||||
if lo <= hi {
|
||||
(lo, hi)
|
||||
} else {
|
||||
(hi, lo)
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3
|
||||
/// One painted stroke: a disc of radius `radius` swept along a polyline.
|
||||
///
|
||||
@@ -651,6 +705,82 @@ pub enum MaskSource {
|
||||
/// thing it was painted on through a crop, a straighten and an export at
|
||||
/// any size.
|
||||
Brush { strokes: Vec<Stroke> },
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Every pixel whose brightness falls in a band — the luminosity mask.
|
||||
///
|
||||
/// The first mask in this enum that is not a shape. A gradient, a stroke
|
||||
/// and a region all answer "where"; this answers "how bright", and the
|
||||
/// selection that comes out has the edge the *photograph* has rather than
|
||||
/// an edge somebody drew near it. That is the whole reason a local edit
|
||||
/// made through one blends: there is no boundary to halo along.
|
||||
///
|
||||
/// # Why the band is in perceptual position, not in linear light
|
||||
///
|
||||
/// The bounds are positions on the same 0..1 lightness scale
|
||||
/// `ops/_helpers.yaml`'s `tone_position` establishes, which is the cube
|
||||
/// root of luminance. Linear light puts middle grey at 0.18, so a band
|
||||
/// stated in it would call four fifths of the range "shadow" and the
|
||||
/// slider's useful travel would be its first inch. The stored numbers are
|
||||
/// where the eye puts them, which is also where the control puts them.
|
||||
///
|
||||
/// # Which image it measures
|
||||
///
|
||||
/// The photograph as captured, before this edit — not the edited result.
|
||||
/// The mask is rasterised in its own pass, ahead of the adjustment that
|
||||
/// samples it, so there is no arrangement in which it could read its own
|
||||
/// output. That is a property worth having rather than a limitation
|
||||
/// worked around: a band over the edited image would slide out from under
|
||||
/// the edit as the edit was made, so raising the highlights would change
|
||||
/// which pixels counted as highlights, and the slider would fight itself.
|
||||
Luminance {
|
||||
/// Lower edge of the band, as a tone position in `0.0..=1.0`.
|
||||
lo: f32,
|
||||
/// Upper edge, in the same units. Never below `lo` — see
|
||||
/// [`MaskSource::luminance_range`], which is where that is kept true.
|
||||
hi: f32,
|
||||
/// How far the band fades in below `lo` and out above `hi`, in the
|
||||
/// same units. Zero is a threshold, and a threshold over a
|
||||
/// photograph's own tones draws contour lines.
|
||||
softness: f32,
|
||||
},
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Every pixel whose colour falls in a band — the skin-tone mask.
|
||||
///
|
||||
/// The counterpart to [`Self::Luminance`], and the pair is not symmetric:
|
||||
/// brightness is one number and colour is two, so this carries an arc of
|
||||
/// hue *and* a range of chroma. Both are needed, and the chroma half is
|
||||
/// the one that is easy to leave out. Hue is meaningless as a colour
|
||||
/// approaches grey — a nearly-neutral wall has a hue, arrived at by
|
||||
/// rounding — so an arc alone selects a haze of noise everywhere the
|
||||
/// picture is desaturated. The lower chroma bound is what keeps that out,
|
||||
/// and it is why the default sits well above zero.
|
||||
///
|
||||
/// Measured in linear sRGB, after the camera matrix, because that is the
|
||||
/// only space in which a stated hue means the colour the photographer
|
||||
/// named. Camera RGB is the body's own primaries: the same arc would
|
||||
/// select a different set of colours on every make of sensor, and a skin
|
||||
/// tone stored on one body would land on foliage on another.
|
||||
Colour {
|
||||
/// Centre of the accepted arc, in turns of the hue circle,
|
||||
/// `0.0..1.0`. Wraps, so 0.98 and 0.02 are close together.
|
||||
hue: f32,
|
||||
/// Half-width of the arc, in turns. Bounded by [`MAX_HUE_WIDTH`].
|
||||
hue_width: f32,
|
||||
/// Lower edge of the accepted chroma, `0.0..=1.0`, in the
|
||||
/// max-minus-min sense `colour_saturation` uses.
|
||||
chroma_lo: f32,
|
||||
/// Upper edge of the accepted chroma. Kept above `chroma_lo` by
|
||||
/// [`MaskSource::colour_range`].
|
||||
chroma_hi: f32,
|
||||
/// The fade at every edge of the band — both ends of the arc and both
|
||||
/// ends of the chroma range. One number rather than three: they are
|
||||
/// the same control to a photographer, who is choosing how strictly
|
||||
/// the selection is drawn, not tuning three of them against each
|
||||
/// other.
|
||||
softness: f32,
|
||||
},
|
||||
}
|
||||
|
||||
impl MaskSource {
|
||||
@@ -663,6 +793,8 @@ impl MaskSource {
|
||||
Self::Linear { .. } => "linear",
|
||||
Self::Radial { .. } => "radial",
|
||||
Self::Brush { .. } => "brush",
|
||||
Self::Luminance { .. } => "luminance",
|
||||
Self::Colour { .. } => "colour",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,6 +805,82 @@ impl MaskSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A luminance band, with the bounds put in order and inside the scale.
|
||||
///
|
||||
/// **The only way one is built**, including on the way in from a sidecar.
|
||||
/// The invariants a band has — bounds inside `0..1`, `lo` at or below
|
||||
/// `hi`, softness not negative — are cheap to state once and impossible
|
||||
/// to remember at four call sites. A crossed pair is the interesting one:
|
||||
/// it comes from a user dragging the lower handle past the upper, and a
|
||||
/// shader given it would select nothing at all rather than the thin band
|
||||
/// the gesture plainly meant.
|
||||
pub fn luminance_range(lo: f32, hi: f32, softness: f32) -> Self {
|
||||
let (lo, hi) = ordered(lo, hi);
|
||||
Self::Luminance {
|
||||
lo,
|
||||
hi,
|
||||
softness: softness.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A colour band, clamped and ordered like [`Self::luminance_range`].
|
||||
///
|
||||
/// The hue is wrapped rather than clamped, because the hue circle has no
|
||||
/// ends: a control dragged past red comes back round to red, where
|
||||
/// clamping would stick it there and make the far side of the circle
|
||||
/// unreachable from one direction.
|
||||
pub fn colour_range(
|
||||
hue: f32,
|
||||
hue_width: f32,
|
||||
chroma_lo: f32,
|
||||
chroma_hi: f32,
|
||||
softness: f32,
|
||||
) -> Self {
|
||||
let (chroma_lo, chroma_hi) = ordered(chroma_lo, chroma_hi);
|
||||
Self::Colour {
|
||||
hue: hue.rem_euclid(1.0),
|
||||
hue_width: hue_width.clamp(0.0, MAX_HUE_WIDTH),
|
||||
chroma_lo,
|
||||
chroma_hi,
|
||||
softness: softness.clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The band a new luminance layer starts on: the brighter half.
|
||||
///
|
||||
/// Not the whole scale, which would select everything and read as a mask
|
||||
/// that had failed to do anything. The upper half is what a photographer
|
||||
/// reaches for first — holding back a sky, shaping a highlight — and it
|
||||
/// is immediately visible on almost any photograph, so the control the
|
||||
/// user then drags starts from something they can see.
|
||||
pub fn highlights() -> Self {
|
||||
Self::luminance_range(0.5, 1.0, DEFAULT_RANGE_SOFTNESS)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The band a new colour layer starts on: the orange–red arc skin sits in.
|
||||
///
|
||||
/// Opinionated, and deliberately. Selecting by colour is a general tool
|
||||
/// and skin is the reason most people reach for it, so starting there
|
||||
/// means the first thing the user sees is the answer to the question they
|
||||
/// asked; starting at red, or at whatever hue zero happens to be, would
|
||||
/// mean the control had to be understood before it did anything.
|
||||
pub fn skin_tones() -> Self {
|
||||
Self::colour_range(0.06, 0.05, 0.15, 1.0, DEFAULT_RANGE_SOFTNESS)
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Whether this mask selects by a property rather than by a shape.
|
||||
///
|
||||
/// Asked by the panel, which offers a band's controls for one and a
|
||||
/// shape's for the other, and by [`MaskLayer::is_stale`].
|
||||
pub fn is_range(&self) -> bool {
|
||||
matches!(self, Self::Luminance { .. } | Self::Colour { .. })
|
||||
}
|
||||
|
||||
/// The strokes, or nothing for a source that is not painted.
|
||||
pub fn strokes(&self) -> &[Stroke] {
|
||||
match self {
|
||||
@@ -947,6 +1155,14 @@ impl MaskLayer {
|
||||
/// adjustment to the whole photograph before a single stroke was made —
|
||||
/// the loud, wrong-looking failure this codebase avoids everywhere else a
|
||||
/// mask can go missing.
|
||||
///
|
||||
/// A range answers yes, and it is worth saying why rather than leaving it
|
||||
/// to the catch-all. A band *can* be empty on a particular photograph — a
|
||||
/// highlight band over a picture with no highlights in it — but nothing
|
||||
/// on this side could know that without rasterising the mask, which is the
|
||||
/// one thing this module promises never to do. So the honest answer is
|
||||
/// that the layer selects whatever the picture has, and an empty result
|
||||
/// is the picture's answer rather than the layer's.
|
||||
fn covers(&self) -> bool {
|
||||
match &self.source {
|
||||
MaskSource::Brush { strokes } => strokes.iter().any(|s| !s.erase && !s.is_empty()),
|
||||
@@ -985,9 +1201,16 @@ impl MaskLayer {
|
||||
// same thing whatever was or was not detected, so nothing about a
|
||||
// new run can invalidate it. Painted strokes are the same: they are
|
||||
// where the user put them, not where a model thought something was.
|
||||
MaskSource::Linear { .. } | MaskSource::Radial { .. } | MaskSource::Brush { .. } => {
|
||||
false
|
||||
}
|
||||
//
|
||||
// A range is the strongest case of all: it is a band over the
|
||||
// photograph's own values, so it does not merely survive a new
|
||||
// segmentation — there is nothing a model could ever say that
|
||||
// would change which pixels it names.
|
||||
MaskSource::Linear { .. }
|
||||
| MaskSource::Radial { .. }
|
||||
| MaskSource::Brush { .. }
|
||||
| MaskSource::Luminance { .. }
|
||||
| MaskSource::Colour { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1935,4 +2158,74 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A band the user dragged inside out is the band they described, not an
|
||||
/// empty one. Dropping it to zero width would make the drag stop dead
|
||||
/// under the finger at the moment the handles crossed.
|
||||
#[test]
|
||||
fn a_crossed_band_is_the_band_between_the_two_numbers() {
|
||||
let MaskSource::Luminance { lo, hi, .. } = MaskSource::luminance_range(0.8, 0.3, 0.1)
|
||||
else {
|
||||
panic!("not a luminance range");
|
||||
};
|
||||
assert_eq!((lo, hi), (0.3, 0.8));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// The hue circle has no ends. Clamping a hue past red would stick it
|
||||
/// there, and the far side of the circle would be unreachable from one
|
||||
/// direction — a control that stops turning is a control that is broken.
|
||||
#[test]
|
||||
fn a_hue_past_the_end_of_the_circle_wraps() {
|
||||
let MaskSource::Colour { hue, .. } = MaskSource::colour_range(1.25, 0.05, 0.1, 1.0, 0.1)
|
||||
else {
|
||||
panic!("not a colour range");
|
||||
};
|
||||
assert!((hue - 0.25).abs() < 1e-6, "hue wrapped to {hue}");
|
||||
|
||||
let MaskSource::Colour { hue, .. } = MaskSource::colour_range(-0.1, 0.05, 0.1, 1.0, 0.1)
|
||||
else {
|
||||
panic!("not a colour range");
|
||||
};
|
||||
assert!((hue - 0.9).abs() < 1e-6, "hue wrapped to {hue}");
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// An arc may not quietly become the whole circle. Half a turn each way is
|
||||
/// every colour there is, arrived at through a control the photographer
|
||||
/// believed was narrowing something.
|
||||
#[test]
|
||||
fn a_hue_arc_cannot_swallow_the_circle() {
|
||||
let MaskSource::Colour { hue_width, .. } =
|
||||
MaskSource::colour_range(0.0, 9.0, 0.1, 1.0, 0.1)
|
||||
else {
|
||||
panic!("not a colour range");
|
||||
};
|
||||
assert_eq!(hue_width, MAX_HUE_WIDTH);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Nothing a model finds can change which pixels a band names, so a range
|
||||
/// layer is never stale — where a subject layer read against a new
|
||||
/// segmentation is exactly that.
|
||||
#[test]
|
||||
fn a_range_never_goes_stale() {
|
||||
assert!(!MaskLayer::new("m1", MaskSource::highlights()).is_stale(7));
|
||||
assert!(!MaskLayer::new("m2", MaskSource::skin_tones()).is_stale(7));
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A range is a rule, and the rule is the whole of what it stores.
|
||||
#[test]
|
||||
fn a_range_is_a_rule_and_not_a_shape() {
|
||||
let source = MaskSource::skin_tones();
|
||||
assert!(source.is_range());
|
||||
assert!(
|
||||
source.strokes().is_empty(),
|
||||
"a range has no geometry to hand out"
|
||||
);
|
||||
assert_eq!(source.kind(), "colour");
|
||||
assert_eq!(MaskSource::highlights().kind(), "luminance");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,6 +1129,46 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
|
||||
let _ = writeln!(out, "feather = {}", format_value(*feather));
|
||||
}
|
||||
MaskSource::Brush { strokes } => write_strokes(out, strokes),
|
||||
// TRACES: FR-DEV-10
|
||||
// `band` rather than a key per edge, and the same key for both range
|
||||
// sources. The two bounds and their softness are one quantity to a
|
||||
// reader — "the band this mask selects" — and splitting them over
|
||||
// three lines makes a hand edit that moves one edge and forgets the
|
||||
// other look like a file that was written that way.
|
||||
MaskSource::Luminance { lo, hi, softness } => {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"band = {} {} {}",
|
||||
format_value(*lo),
|
||||
format_value(*hi),
|
||||
format_value(*softness)
|
||||
);
|
||||
}
|
||||
MaskSource::Colour {
|
||||
hue,
|
||||
hue_width,
|
||||
chroma_lo,
|
||||
chroma_hi,
|
||||
softness,
|
||||
} => {
|
||||
// The chroma half goes out as `band` so that the two range
|
||||
// sources share a key for the same idea, and the arc gets its
|
||||
// own: a hue is a position on a circle and its neighbours are not
|
||||
// bounds in the sense the chroma pair are.
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"hue = {} {}",
|
||||
format_value(*hue),
|
||||
format_value(*hue_width)
|
||||
);
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"band = {} {} {}",
|
||||
format_value(*chroma_lo),
|
||||
format_value(*chroma_hi),
|
||||
format_value(*softness)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if layer.invert {
|
||||
@@ -1277,6 +1317,16 @@ struct PartialMask {
|
||||
angle: f32,
|
||||
width: f32,
|
||||
feather: f32,
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A range source's band: its two bounds and the fade at each edge.
|
||||
///
|
||||
/// One triple for both range kinds — tone positions for a luminance
|
||||
/// layer, chroma for a colour one — because they are the same line in the
|
||||
/// file and reading them into two sets of fields would be two ways to
|
||||
/// spell one thing.
|
||||
band: (f32, f32, f32),
|
||||
/// A colour range's arc: centre and half-width, in turns.
|
||||
hue: (f32, f32),
|
||||
invert: bool,
|
||||
opacity: f32,
|
||||
enabled: bool,
|
||||
@@ -1311,6 +1361,11 @@ impl PartialMask {
|
||||
angle: 0.0,
|
||||
width: 0.0,
|
||||
feather: 0.0,
|
||||
// The defaults a new layer gets, so a block that names a range
|
||||
// source and nothing else reads back as the mask the panel would
|
||||
// have made rather than as an empty band selecting nothing.
|
||||
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
|
||||
hue: (0.06, 0.05),
|
||||
invert: false,
|
||||
opacity: 1.0,
|
||||
enabled: true,
|
||||
@@ -1356,6 +1411,12 @@ impl PartialMask {
|
||||
"angle" => self.angle = value.parse().unwrap_or(0.0),
|
||||
"width" => self.width = value.parse().unwrap_or(0.0),
|
||||
"feather" => self.feather = value.parse().unwrap_or(0.0),
|
||||
// TRACES: FR-DEV-10
|
||||
// Kept as written and ordered later, in `MaskSource::*_range`.
|
||||
// Clamping here as well would be a second place the band's
|
||||
// invariants live, and the two would drift.
|
||||
"band" => self.band = triple(value).unwrap_or(self.band),
|
||||
"hue" => self.hue = pair(value).unwrap_or(self.hue),
|
||||
// A cache, so an unreadable one is dropped rather than refused:
|
||||
// the layer still says what it selects, and the worst a `None`
|
||||
// here costs is a model run. Refusing the layer over it would
|
||||
@@ -1454,6 +1515,18 @@ impl PartialMask {
|
||||
"brush" => MaskSource::Brush {
|
||||
strokes: self.strokes,
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// Through the constructors, not built by hand: they are where the
|
||||
// band's invariants are kept, and a file is exactly the input
|
||||
// that has not been through them.
|
||||
"luminance" => MaskSource::luminance_range(self.band.0, self.band.1, self.band.2),
|
||||
"colour" => MaskSource::colour_range(
|
||||
self.hue.0,
|
||||
self.hue.1,
|
||||
self.band.0,
|
||||
self.band.1,
|
||||
self.band.2,
|
||||
),
|
||||
other => {
|
||||
log::warn!(
|
||||
"sidecar: unknown mask source '{other}'; skipping layer {}",
|
||||
@@ -1517,9 +1590,23 @@ impl PartialMask {
|
||||
/// Two whitespace-separated floats.
|
||||
fn pair(value: &str) -> Option<(f32, f32)> {
|
||||
let mut it = value.split_whitespace();
|
||||
let a = it.next()?.parse().ok()?;
|
||||
let b = it.next()?.parse().ok()?;
|
||||
Some((a, b))
|
||||
let a: f32 = it.next()?.parse().ok()?;
|
||||
let b: f32 = it.next()?.parse().ok()?;
|
||||
// A NaN parses — `"nan"` is a valid float — and then survives every clamp
|
||||
// downstream, because every comparison against it is false. It reaches a
|
||||
// uniform, and a mask whose geometry or band is NaN selects nothing while
|
||||
// looking, in the file and in the panel, exactly like one that should.
|
||||
(a.is_finite() && b.is_finite()).then_some((a, b))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Three whitespace-separated floats — a range mask's band.
|
||||
fn triple(value: &str) -> Option<(f32, f32, f32)> {
|
||||
let mut it = value.split_whitespace();
|
||||
let a: f32 = it.next()?.parse().ok()?;
|
||||
let b: f32 = it.next()?.parse().ok()?;
|
||||
let c: f32 = it.next()?.parse().ok()?;
|
||||
(a.is_finite() && b.is_finite() && c.is_finite()).then_some((a, b, c))
|
||||
}
|
||||
|
||||
/// Format a value without a trailing `.0` on whole numbers, and without
|
||||
|
||||
@@ -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