Let one mask be built from more than one selection, and paint into it
A mask the model draws arrives approximately right — stopping inside a shoulder, leaking into the hair — and FR-DEV-3's edge controls move the *whole* boundary, so no value of feather or dilation fixes two errors that go opposite ways. What fixes them is a second selection joined to the first, and a layer that held exactly one source had nowhere to put one. The brush the core has had all along was reachable from no control in the application. A layer is now an ordered list of parts. Each names a source and how it joins the mask before it — added to it, or taken out of it — and carries its own edge treatment, because a model's soft coverage and a stroke painted where it stopped short do not want the same feather. Invert and opacity stay on the layer, where the composed shader already reads them. The sidecar grows `[part]` blocks and nothing else. A layer of one part writes exactly the bytes it always did; a mask block with no part blocks after it reads back as one part; and a stroke, a join or a source this build cannot read costs that part rather than the layer. So every sidecar in every library still parses to the edit it always was. On the device the parts fold into the layer's one slice, so eight layers still cost eight channels: union is a `max` blend and subtraction is the erase blend the brush already used. A part is drawn into a scratch texture before it is joined, and that is not incidental — an erase stroke means a hole in *that part*, not a hole in the mask, and drawn straight onto the accumulator it would punch through the subject underneath. A layer of one part skips all of it and takes the path it always took. In the interface: a part list under the selected layer with a chip saying which way each joins, Add and Subtract beside it, a Select/Paint/Erase strip with the brush's size, hardness and flow, and a drag on the photograph that paints. Pressing Paint on a mask that cannot hold a stroke joins a part that can, rather than explaining that a subject is not a brush. A whole stroke is one step in the history. The edge controls now shape the part that is selected rather than the layer, which is the one behaviour change to an existing control: with a correction selected, the feather slider softens the correction and leaves the model's mask alone.
This commit is contained in:
@@ -161,7 +161,7 @@ fn main() {
|
||||
drain.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
// A touch of feather, or the colour stops dead on the model's outline and
|
||||
// the eye goes straight to the edge instead of to the subject.
|
||||
drain.feather = 0.02;
|
||||
drain.base_mut().feather = 0.02;
|
||||
pop.push(drain);
|
||||
|
||||
render_stack(
|
||||
@@ -183,14 +183,14 @@ fn main() {
|
||||
|
||||
let mut brighter = subject_layer("m1", index, subject);
|
||||
brighter.set_param("exposure", ParamId("exposure"), 0.45);
|
||||
brighter.feather = 0.015;
|
||||
brighter.base_mut().feather = 0.015;
|
||||
lift.push(brighter);
|
||||
|
||||
let mut darker = subject_layer("m2", index, subject);
|
||||
darker.invert = true;
|
||||
darker.set_param("exposure", ParamId("exposure"), -0.55);
|
||||
darker.set_param("saturation", ParamId("saturation"), -25.0);
|
||||
darker.feather = 0.03;
|
||||
darker.base_mut().feather = 0.03;
|
||||
lift.push(darker);
|
||||
|
||||
render_stack(
|
||||
@@ -221,9 +221,9 @@ fn main() {
|
||||
let mut layer = subject_layer("m1", index, subject);
|
||||
layer.invert = true;
|
||||
layer.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
layer.feather = 0.004;
|
||||
layer.morphology = morphology;
|
||||
layer.morph_radius = radius;
|
||||
layer.base_mut().feather = 0.004;
|
||||
layer.base_mut().morphology = morphology;
|
||||
layer.base_mut().morph_radius = radius;
|
||||
stack.push(layer);
|
||||
|
||||
render_stack(
|
||||
@@ -307,14 +307,14 @@ fn render_stack(
|
||||
pw as usize,
|
||||
ph as usize,
|
||||
128,
|
||||
match layer.morphology {
|
||||
match layer.base().morphology {
|
||||
Morphology::None => dr_segment::Morphology::None,
|
||||
Morphology::Dilate => dr_segment::Morphology::Dilate,
|
||||
Morphology::Erode => dr_segment::Morphology::Erode,
|
||||
Morphology::Close => dr_segment::Morphology::Close,
|
||||
Morphology::Open => dr_segment::Morphology::Open,
|
||||
},
|
||||
layer.morph_radius * pw.min(ph) as f32,
|
||||
layer.base().morph_radius * pw.min(ph) as f32,
|
||||
)
|
||||
.distance
|
||||
})
|
||||
|
||||
+397
-102
@@ -41,7 +41,7 @@
|
||||
//! `region_count`. The compaction is CPU-side and once per image, which is the
|
||||
//! same place and cadence the region adjacency graph is already built at.
|
||||
|
||||
use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||||
use dr_pipeline::mask::{Join, MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
@@ -95,6 +95,12 @@ struct MaskParams {
|
||||
/// The same packing the generated adjust shader uses, so the two agree by
|
||||
/// construction rather than by inspection.
|
||||
as_shot_wb: [f32; 4],
|
||||
/// Whether this part is turned over before it joins the mask. Read by the
|
||||
/// combine pass and by nothing else — see `fs_combine`.
|
||||
invert: u32,
|
||||
/// A uniform buffer is a multiple of sixteen bytes, and the flag above
|
||||
/// takes four of them.
|
||||
_pad: [u32; 3],
|
||||
}
|
||||
|
||||
/// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it.
|
||||
@@ -383,6 +389,19 @@ pub struct MaskPass {
|
||||
/// `dst(1 - a)` to erase.
|
||||
brush_add: wgpu::RenderPipeline,
|
||||
brush_erase: wgpu::RenderPipeline,
|
||||
/// Reads a part back out of [`Self::scratch`] and blends it into the
|
||||
/// layer's slice. The set operation is the blend state, so these two are
|
||||
/// one shader as well.
|
||||
combine_layout: wgpu::BindGroupLayout,
|
||||
combine_union: wgpu::RenderPipeline,
|
||||
combine_subtract: wgpu::RenderPipeline,
|
||||
/// Where a part is drawn before it is joined.
|
||||
///
|
||||
/// One texture for the whole stack rather than one per layer, because
|
||||
/// layers rasterise in sequence and a part is read back immediately after
|
||||
/// it is drawn. Allocated the first time a layer has more than one part,
|
||||
/// so a library of unedited masks never pays for it.
|
||||
scratch: Option<Scratch>,
|
||||
array: Option<MaskArray>,
|
||||
/// How many times the array texture has been (re)allocated.
|
||||
///
|
||||
@@ -551,6 +570,88 @@ impl MaskPass {
|
||||
"mask-brush-add",
|
||||
blend_state(wgpu::BlendFactor::One, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
// The pipelines that join one part to the mask so far. The blend
|
||||
// state is the set operation and the shader is the same three
|
||||
// vertices either way — which is why adding a way to combine masks
|
||||
// cost no shader arithmetic at all.
|
||||
let combine_layout =
|
||||
ctx.device
|
||||
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("mask-combine-bgl"),
|
||||
entries: &[
|
||||
uniform_entry(0),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 7,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
// Loaded texel by texel at matching size, so
|
||||
// there is nothing to filter and no sampler.
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let combine_pipeline_layout =
|
||||
ctx.device
|
||||
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("mask-combine-layout"),
|
||||
bind_group_layouts: &[Some(&combine_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let combine = |label, blend| {
|
||||
ctx.device
|
||||
.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(&combine_pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_combine"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: MaskArray::FORMAT,
|
||||
blend: Some(blend),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
|
||||
// `max`, not source-over: a union must not build up where two parts
|
||||
// overlap. Two selections that both half-cover a pixel select it half
|
||||
// — adding them would make the overlap of two soft edges harder than
|
||||
// either, which is a seam exactly where a photographer joined two
|
||||
// things to avoid one.
|
||||
let combine_union = combine(
|
||||
"mask-combine-union",
|
||||
wgpu::BlendState {
|
||||
color: MAX_BLEND,
|
||||
alpha: MAX_BLEND,
|
||||
},
|
||||
);
|
||||
// `dst * (1 - src)`, which is the erase blend one level up: what the
|
||||
// mask had, minus what this part covers, in proportion to how much of
|
||||
// it the part covers.
|
||||
let combine_subtract = combine(
|
||||
"mask-combine-subtract",
|
||||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc),
|
||||
);
|
||||
|
||||
// The same, with the deposit thrown away: coverage is only ever taken
|
||||
// off what earlier strokes on this layer put down. There is no negative
|
||||
// coverage to accumulate, so erasing an unpainted layer is a no-op
|
||||
@@ -577,6 +678,10 @@ impl MaskPass {
|
||||
brush_layout,
|
||||
brush_add,
|
||||
brush_erase,
|
||||
combine_layout,
|
||||
combine_union,
|
||||
combine_subtract,
|
||||
scratch: None,
|
||||
array: None,
|
||||
allocations: 0,
|
||||
placeholder,
|
||||
@@ -617,77 +722,130 @@ impl MaskPass {
|
||||
});
|
||||
|
||||
for (slot, layer) in stack.active().enumerate().take(MAX_LAYERS) {
|
||||
let field = match (&layer.source, labels) {
|
||||
(MaskSource::Regions { .. }, None) => {
|
||||
log::warn!(
|
||||
"mask layer {} is a region mask with no segmentation loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
(MaskSource::Regions { .. }, Some(f)) => f,
|
||||
(_, _) => &self.placeholder,
|
||||
};
|
||||
// **The path a mask with one part takes is the path every mask
|
||||
// took before parts existed**: drawn straight into the layer's
|
||||
// slice, cleared by the draw itself. Nothing about an unedited
|
||||
// library's rendering changes, and the scratch texture is never
|
||||
// allocated for it.
|
||||
//
|
||||
// An inverted base is the exception, because turning a part over
|
||||
// is done where it is read back rather than where it is drawn —
|
||||
// a brush deposits dabs and cannot know what the rest of the
|
||||
// frame is. See `fs_combine`.
|
||||
let direct = layer.parts().len() == 1 && !layer.base().invert;
|
||||
if !direct {
|
||||
self.ensure_scratch(width, height)?;
|
||||
}
|
||||
|
||||
// A subject layer whose instance is missing is skipped for the
|
||||
// same reason a region layer without a segmentation is: an absent
|
||||
// mask that defaults to "everything" would apply the adjustment to
|
||||
// the whole photograph, which is a much louder failure than none.
|
||||
// Indexed by *slot*, not by the instance the layer names: the
|
||||
// fields are built per layer, in this same order, because two
|
||||
// layers over one subject can carry different morphology.
|
||||
let subject = match &layer.source {
|
||||
// Category alongside Subject: both are model coverage turned
|
||||
// into a distance field, both are built per layer in this same
|
||||
// order, and leaving a category out of here is precisely the
|
||||
// failure the comment above warns about — it binds the 1x1
|
||||
// placeholder, so the mask covers everything and the
|
||||
// adjustment silently goes global.
|
||||
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||
match subjects.filter(|s| slot < s.len()) {
|
||||
Some(s) => (s, slot),
|
||||
None => {
|
||||
log::warn!("mask layer {} has no distance field; skipping", layer.id);
|
||||
continue;
|
||||
for (index, part) in layer.parts().iter().enumerate() {
|
||||
let base = index == 0;
|
||||
let field = match (&part.source, labels) {
|
||||
(MaskSource::Regions { .. }, None) => {
|
||||
log::warn!(
|
||||
"mask layer {} is a region mask with no segmentation loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
(MaskSource::Regions { .. }, Some(f)) => f,
|
||||
(_, _) => &self.placeholder,
|
||||
};
|
||||
|
||||
// A subject part whose instance is missing is skipped for the
|
||||
// same reason a region part without a segmentation is: an
|
||||
// absent mask that defaults to "everything" would apply the
|
||||
// adjustment to the whole photograph, which is a much louder
|
||||
// failure than none.
|
||||
//
|
||||
// Indexed by *slot*, not by the instance the part names: the
|
||||
// fields are built per layer, in this same order, because two
|
||||
// layers over one subject can carry different morphology.
|
||||
// Which is also why only a base part can have one — a model
|
||||
// part joined to a mask has no field built for it yet, and it
|
||||
// is skipped rather than drawn against a placeholder that
|
||||
// would cover the frame.
|
||||
let subject = match &part.source {
|
||||
// Category alongside Subject: both are model coverage
|
||||
// turned into a distance field, both are built per layer
|
||||
// in this same order, and leaving a category out of here
|
||||
// is precisely the failure the comment above warns about —
|
||||
// it binds the 1x1 placeholder, so the mask covers
|
||||
// everything and the adjustment silently goes global.
|
||||
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||
match subjects.filter(|s| base && slot < s.len()) {
|
||||
Some(s) => (s, slot),
|
||||
None => {
|
||||
log::warn!(
|
||||
"part {} of mask layer {} has no distance field; skipping",
|
||||
part.id,
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
|
||||
// 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,
|
||||
};
|
||||
// TRACES: FR-DEV-10
|
||||
// A range part 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 part that visibly did not render.
|
||||
let image = match (&part.source, source) {
|
||||
(s, None) if s.is_range() => {
|
||||
log::warn!(
|
||||
"mask layer {} selects a range with no image loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
if base {
|
||||
break;
|
||||
}
|
||||
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)
|
||||
let params = self.params(part, field, image, width, height);
|
||||
let target = if direct {
|
||||
self.slice_view(slot as u32)
|
||||
} else {
|
||||
self.scratch_view()
|
||||
};
|
||||
|
||||
match &part.source {
|
||||
MaskSource::Brush { strokes } => {
|
||||
self.draw_brush(&mut encoder, &target, ¶ms, strokes, width, height)
|
||||
}
|
||||
_ => {
|
||||
let selected = self.selection_buffer(part, field);
|
||||
self.draw(
|
||||
&mut encoder,
|
||||
&target,
|
||||
¶ms,
|
||||
field,
|
||||
&selected,
|
||||
subject,
|
||||
image,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let selected = self.selection_buffer(layer, field);
|
||||
self.draw(
|
||||
&mut encoder,
|
||||
slot as u32,
|
||||
¶ms,
|
||||
field,
|
||||
&selected,
|
||||
subject,
|
||||
image,
|
||||
);
|
||||
|
||||
if !direct {
|
||||
// The first part joins a cleared slice, so it lands
|
||||
// exactly as it was drawn whichever way it says it joins —
|
||||
// there is nothing yet for a subtraction to take away
|
||||
// from, and a mask that began by subtracting from nothing
|
||||
// would render as empty however it was painted afterwards.
|
||||
let join = if base { Join::Union } else { part.join };
|
||||
self.combine(&mut encoder, slot as u32, join, base, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -708,7 +866,7 @@ impl MaskPass {
|
||||
|
||||
fn params(
|
||||
&self,
|
||||
layer: &dr_pipeline::mask::MaskLayer,
|
||||
part: &dr_pipeline::mask::MaskPart,
|
||||
field: &LabelField,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
@@ -758,9 +916,11 @@ impl MaskPass {
|
||||
[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 }],
|
||||
invert: u32::from(part.invert),
|
||||
_pad: [0; 3],
|
||||
};
|
||||
|
||||
match &layer.source {
|
||||
match &part.source {
|
||||
// `softness` carries the layer's feather. The model's coverage is
|
||||
// already a soft sigmoid, so zero means "use the edge the model
|
||||
// drew" rather than "hard edge" — the one place in this shader
|
||||
@@ -780,12 +940,12 @@ impl MaskPass {
|
||||
MaskParams {
|
||||
mode: MODE_SUBJECT,
|
||||
// `softness` is the feather half-width in pixels.
|
||||
softness: (layer.feather * short).max(0.0),
|
||||
softness: (part.feather * short).max(0.0),
|
||||
// `angle` carries the morphology offset — reused rather
|
||||
// than padded, since a subject layer has no ellipse to
|
||||
// rotate.
|
||||
angle: morph_offset(layer) * short,
|
||||
falloff: falloff_code(layer.falloff),
|
||||
angle: morph_offset(part) * short,
|
||||
falloff: falloff_code(part.falloff),
|
||||
..base
|
||||
}
|
||||
}
|
||||
@@ -871,7 +1031,7 @@ impl MaskPass {
|
||||
fn draw_brush(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
target: &wgpu::TextureView,
|
||||
params: &MaskParams,
|
||||
strokes: &[Stroke],
|
||||
width: u32,
|
||||
@@ -933,19 +1093,10 @@ impl MaskPass {
|
||||
})
|
||||
});
|
||||
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-brush-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -991,11 +1142,11 @@ impl MaskPass {
|
||||
/// One byte-flag per region, or a single zero for a non-region layer.
|
||||
fn selection_buffer(
|
||||
&self,
|
||||
layer: &dr_pipeline::mask::MaskLayer,
|
||||
part: &dr_pipeline::mask::MaskPart,
|
||||
field: &LabelField,
|
||||
) -> wgpu::Buffer {
|
||||
let mut flags = vec![0u32; field.region_count.max(1) as usize];
|
||||
if let MaskSource::Regions { ids, .. } = &layer.source {
|
||||
if let MaskSource::Regions { ids, .. } = &part.source {
|
||||
for &id in ids {
|
||||
if let Some(slot) = flags.get_mut(id as usize) {
|
||||
*slot = 1;
|
||||
@@ -1016,7 +1167,7 @@ impl MaskPass {
|
||||
fn draw(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
target: &wgpu::TextureView,
|
||||
params: &MaskParams,
|
||||
field: &LabelField,
|
||||
selected: &wgpu::Buffer,
|
||||
@@ -1072,19 +1223,10 @@ impl MaskPass {
|
||||
// The array slice is selected by the attachment rather than by a
|
||||
// uniform the shader reads — one fewer value that can disagree with
|
||||
// where the pass actually writes.
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
let view = array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view: target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -1105,6 +1247,140 @@ impl MaskPass {
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// A view of one layer's slice of the array.
|
||||
fn slice_view(&self, slot: u32) -> wgpu::TextureView {
|
||||
// The array slice is selected by the attachment rather than by a
|
||||
// uniform the shader reads — one fewer value that can disagree with
|
||||
// where the pass actually writes.
|
||||
let array = self.array.as_ref().expect("array ensured by caller");
|
||||
array.texture.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-slice"),
|
||||
dimension: Some(wgpu::TextureViewDimension::D2),
|
||||
base_array_layer: slot,
|
||||
array_layer_count: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn scratch_view(&self) -> wgpu::TextureView {
|
||||
self.scratch
|
||||
.as_ref()
|
||||
.expect("scratch ensured by caller")
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor {
|
||||
label: Some("mask-part"),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Blend the part sitting in [`Self::scratch`] into a layer's slice.
|
||||
///
|
||||
/// `first` clears the slice instead of loading it, which is both cheaper
|
||||
/// on a tiler and the only thing that makes the fold start from nothing
|
||||
/// covered rather than from whatever the last rasterisation left.
|
||||
fn combine(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
slot: u32,
|
||||
join: Join,
|
||||
first: bool,
|
||||
params: &MaskParams,
|
||||
) {
|
||||
let params_buf = self
|
||||
.ctx
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mask-combine-params"),
|
||||
contents: bytemuck::bytes_of(params),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let bind_group = self
|
||||
.ctx
|
||||
.device
|
||||
.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("mask-combine-bind"),
|
||||
layout: &self.combine_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: params_buf.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 7,
|
||||
resource: wgpu::BindingResource::TextureView(&self.scratch_view()),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let target = self.slice_view(slot);
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("mask-combine-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &target,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: if first {
|
||||
wgpu::LoadOp::Clear(wgpu::Color::BLACK)
|
||||
} else {
|
||||
wgpu::LoadOp::Load
|
||||
},
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
|
||||
pass.set_pipeline(match join {
|
||||
Join::Union => &self.combine_union,
|
||||
Join::Subtract => &self.combine_subtract,
|
||||
});
|
||||
pass.set_bind_group(0, &bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// The texture a part is drawn in before it is joined.
|
||||
///
|
||||
/// Allocated on the first mask that has more than one part and kept at the
|
||||
/// rasterisation size, which is the same size the array is: a part and the
|
||||
/// slice it joins are compared texel for texel, so there is nothing to
|
||||
/// scale and nothing to sample between.
|
||||
fn ensure_scratch(&mut self, width: u32, height: u32) -> Result<(), GpuError> {
|
||||
if self
|
||||
.scratch
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.width == width && s.height == height)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("mask-scratch"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: MaskArray::FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
self.scratch = Some(Scratch {
|
||||
texture,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> {
|
||||
if self
|
||||
.array
|
||||
@@ -1147,6 +1423,25 @@ impl MaskPass {
|
||||
}
|
||||
}
|
||||
|
||||
/// The texture one part is drawn into on its way into a layer's slice.
|
||||
struct Scratch {
|
||||
texture: wgpu::Texture,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// `max(dst, src)` — the union of two parts.
|
||||
///
|
||||
/// Not source-over, which would build up: two parts that each half-cover a
|
||||
/// pixel select it half, and adding them would make the overlap of two soft
|
||||
/// edges harder than either of them, drawing a seam exactly where a
|
||||
/// photographer joined two selections to avoid one.
|
||||
const MAX_BLEND: wgpu::BlendComponent = wgpu::BlendComponent {
|
||||
src_factor: wgpu::BlendFactor::One,
|
||||
dst_factor: wgpu::BlendFactor::One,
|
||||
operation: wgpu::BlendOperation::Max,
|
||||
};
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A single black texel, bound at the image slot for a mask that is not a
|
||||
/// range.
|
||||
@@ -1192,11 +1487,11 @@ fn field_short_edge(width: u32, height: u32) -> f32 {
|
||||
/// Zero for closing and opening: those are folded into the field itself when
|
||||
/// it is built, because their second half acts on a shape the original field
|
||||
/// does not describe.
|
||||
fn morph_offset(layer: &dr_pipeline::mask::MaskLayer) -> f32 {
|
||||
fn morph_offset(part: &dr_pipeline::mask::MaskPart) -> f32 {
|
||||
use dr_pipeline::mask::Morphology;
|
||||
match layer.morphology {
|
||||
Morphology::Dilate => layer.morph_radius,
|
||||
Morphology::Erode => -layer.morph_radius,
|
||||
match part.morphology {
|
||||
Morphology::Dilate => part.morph_radius,
|
||||
Morphology::Erode => -part.morph_radius,
|
||||
Morphology::None | Morphology::Close | Morphology::Open => 0.0,
|
||||
}
|
||||
}
|
||||
@@ -1362,11 +1657,11 @@ mod tests {
|
||||
fn painted(gestures: &[Gesture]) -> MaskLayer {
|
||||
let mut layer = lit(MaskSource::brush());
|
||||
for (erase, radius, path) in gestures {
|
||||
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
|
||||
layer.begin_stroke(0, *erase, *radius, 0.5, 1.0);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
layer.extend_stroke(0, x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
@@ -78,6 +78,22 @@ struct MaskParams {
|
||||
// rgb: as-shot white balance. w: non-zero when the source arrived
|
||||
// gamma-encoded rather than linear.
|
||||
as_shot_wb: vec4<f32>,
|
||||
|
||||
// Whether this part is turned over before it joins the mask.
|
||||
//
|
||||
// Read by `fs_combine` and by nothing else, deliberately. A brush deposits
|
||||
// dabs onto an empty field and has no idea what the rest of the frame is,
|
||||
// so a stroke shader cannot invert anything; doing it where the finished
|
||||
// part is read back is the one place that works for every kind of source.
|
||||
invert: u32,
|
||||
// Three scalars rather than a `vec3<u32>`: a three-component vector is
|
||||
// aligned to sixteen bytes in the uniform address space, so it would sit
|
||||
// at offset 144 and make this struct 160 bytes against the Rust side's
|
||||
// 144 — a mismatch wgpu reports as a binding too small for the shader,
|
||||
// several layers away from the padding that caused it.
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
_pad2: u32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: MaskParams;
|
||||
@@ -597,3 +613,29 @@ fn fs_brush(in: BrushVertex) -> @location(0) vec4<f32> {
|
||||
|
||||
return vec4<f32>(clamp(coverage * s.flow, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Joining one part to the mask so far
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A layer's mask is a fold over its parts, and the set operation is the *blend
|
||||
// state* rather than arithmetic here: union is `max(dst, src)`, subtraction is
|
||||
// `dst * (1 - src)`. Both are fixed-function, so joining a part costs one
|
||||
// full-screen draw and no second texture beyond the one being read.
|
||||
//
|
||||
// # Why a part is drawn aside first, rather than straight onto the mask
|
||||
//
|
||||
// Because an erase stroke inside a part means "a hole in *this* part", not "a
|
||||
// hole in the mask". Painted straight onto the accumulator it would take away
|
||||
// whatever the parts before it had put there — so a correction that tidied its
|
||||
// own edge would punch through the subject underneath, and the failure would
|
||||
// look like the model's mask had holes in it.
|
||||
|
||||
@group(0) @binding(7) var part_mask: texture_2d<f32>;
|
||||
|
||||
@fragment
|
||||
fn fs_combine(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let v = textureLoad(part_mask, vec2<i32>(i32(pos.x), i32(pos.y)), 0).r;
|
||||
let m = select(v, 1.0 - v, p.invert != 0u);
|
||||
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, GpuContext, LabelField, MaskPass};
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource, MaskStack};
|
||||
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, MaskStack};
|
||||
use dr_pipeline::operation::compose_full;
|
||||
use dr_pipeline::spot::SpotSet;
|
||||
use dr_pipeline::{ops, EditGraph, Framing};
|
||||
@@ -286,11 +286,11 @@ type Gesture = (bool, f32, f32, Vec<(f32, f32)>);
|
||||
fn painted(gestures: &[Gesture]) -> MaskLayer {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
for (erase, radius, flow, path) in gestures {
|
||||
layer.begin_stroke(*erase, *radius, 0.9, *flow);
|
||||
layer.begin_stroke(0, *erase, *radius, 0.9, *flow);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
layer.extend_stroke(0, x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
layer
|
||||
}
|
||||
@@ -505,9 +505,9 @@ fn hardness_decides_how_quickly_the_edge_falls_away() {
|
||||
|
||||
let edge = |hardness: f32| {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
layer.begin_stroke(false, 0.4, hardness, 1.0);
|
||||
layer.extend_stroke(0.5, 0.5);
|
||||
layer.end_stroke();
|
||||
layer.begin_stroke(0, false, 0.4, hardness, 1.0);
|
||||
layer.extend_stroke(0, 0.5, 0.5);
|
||||
layer.end_stroke(0);
|
||||
|
||||
let pixels = render(&ctx, &stack_of(layer), None);
|
||||
// How many pixels along the centre row are neither fully painted nor
|
||||
@@ -608,3 +608,184 @@ fn an_empty_stack_renders_exactly_as_the_unmasked_path() {
|
||||
let masked = render(&ctx, &MaskStack::new(), None);
|
||||
assert_eq!(plain, masked, "an empty mask stack must be a no-op");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parts — a mask built from more than one selection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Everything, so a part joined to it has something to change.
|
||||
fn whole_frame() -> MaskSource {
|
||||
MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0, 1],
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint one gesture into a part of a layer, at a radius large enough that a
|
||||
/// 32-pixel frame can tell where it landed.
|
||||
fn paint(layer: &mut MaskLayer, part: usize, erase: bool, path: &[(f32, f32)]) {
|
||||
assert!(
|
||||
layer.begin_stroke(part, erase, 0.2, 1.0, 1.0),
|
||||
"the layer had no room for a stroke"
|
||||
);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(part, x, y);
|
||||
}
|
||||
layer.end_stroke(part);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_union_part_adds_what_the_base_did_not_cover() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
});
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
paint(&mut layer, 1, false, &[(0.85, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, 4, 16) > 200,
|
||||
"the base region is still masked"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 27, 16) > 200,
|
||||
"and the painted part joined the other half in"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 20, 2),
|
||||
128,
|
||||
"while what neither covers is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subtract_part_takes_a_bite_out_of_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(whole_frame());
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Subtract)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 16, 16),
|
||||
128,
|
||||
"the middle was taken back out of the mask"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 1, 1) > 200,
|
||||
"and the corner the stroke never reached is still in it"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The test the scratch texture exists for.** An erase stroke means a hole in
|
||||
/// the part it was painted into, not a hole in the mask: drawn straight onto
|
||||
/// the accumulator it would take away whatever the parts before it had put
|
||||
/// there, so tidying the edge of a correction would punch through the subject
|
||||
/// underneath — and the failure would read as the model's mask having holes.
|
||||
#[test]
|
||||
fn an_erase_stroke_holes_its_own_part_and_not_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut layer = brighten(whole_frame());
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 1, true, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert!(
|
||||
luma_at(&pixels, 16, 16) > 200,
|
||||
"the base still covers where the correction erased itself"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inverted_part_joins_everything_it_did_not_paint() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
// A base that covers nothing, so what shows is the part alone.
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
paint(&mut layer, 0, false, &[(0.1, 0.1)]);
|
||||
assert!(layer.push_part(MaskPart::painted("p2", Join::Union)));
|
||||
layer.parts_mut()[1].invert = true;
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
let pixels = render(&ctx, &stack, Some(&split_field(&ctx)));
|
||||
|
||||
assert_eq!(
|
||||
luma_at(&pixels, 16, 16),
|
||||
128,
|
||||
"where the inverted part was painted is outside the mask"
|
||||
);
|
||||
assert!(
|
||||
luma_at(&pixels, 30, 30) > 200,
|
||||
"and everywhere it was not is inside it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Parts fold in order, so the same two selections joined the other way round
|
||||
/// are a different mask. A subtraction that lands before the part it was meant
|
||||
/// to cut into would take away nothing at all.
|
||||
#[test]
|
||||
fn the_order_parts_are_joined_in_is_the_mask() {
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no adapter; skipping");
|
||||
return;
|
||||
};
|
||||
|
||||
let render_pair = |cut_first: bool| {
|
||||
let mut layer = brighten(MaskSource::brush());
|
||||
if cut_first {
|
||||
layer.push_part(MaskPart::painted("p2", Join::Subtract));
|
||||
layer.push_part(MaskPart::painted("p3", Join::Union));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 2, false, &[(0.5, 0.5)]);
|
||||
} else {
|
||||
layer.push_part(MaskPart::painted("p2", Join::Union));
|
||||
layer.push_part(MaskPart::painted("p3", Join::Subtract));
|
||||
paint(&mut layer, 1, false, &[(0.5, 0.5)]);
|
||||
paint(&mut layer, 2, false, &[(0.5, 0.5)]);
|
||||
}
|
||||
let mut stack = MaskStack::new();
|
||||
stack.push(layer);
|
||||
render(&ctx, &stack, Some(&split_field(&ctx)))
|
||||
};
|
||||
|
||||
assert!(
|
||||
luma_at(&render_pair(true), 16, 16) > 200,
|
||||
"adding after a subtraction leaves the addition standing"
|
||||
);
|
||||
assert_eq!(
|
||||
luma_at(&render_pair(false), 16, 16),
|
||||
128,
|
||||
"and subtracting after an addition takes it away again"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ fn brightening_stack() -> MaskStack {
|
||||
},
|
||||
);
|
||||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||||
layer.feather = 0.0;
|
||||
layer.base_mut().feather = 0.0;
|
||||
stack.push(layer);
|
||||
stack
|
||||
}
|
||||
|
||||
@@ -989,12 +989,26 @@ impl EditGraph {
|
||||
// out of step the first time a variant gains a field — silently,
|
||||
// and showing as a mask that stops updating. `Debug` cannot fall
|
||||
// out of step, because it is derived from the definition itself.
|
||||
colour = hash_bytes(colour, format!("{:?}", layer.source).as_bytes());
|
||||
colour = mix(colour, u64::from(layer.enabled));
|
||||
colour = mix(colour, u64::from(layer.invert));
|
||||
colour = hash_bytes(colour, layer.falloff.name().as_bytes());
|
||||
for v in [layer.opacity, layer.feather, layer.morph_radius] {
|
||||
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
||||
colour = mix(
|
||||
colour,
|
||||
u64::from(crate::operation::canonical_bits(layer.opacity)),
|
||||
);
|
||||
// Every part, in order, because the mask is the fold over them: a
|
||||
// part added, removed, reshaped or joined the other way round is a
|
||||
// different shape even when nothing else moved. The order is
|
||||
// folded in by construction — the same parts joined the other way
|
||||
// round hash differently because they arrive in the other order.
|
||||
for part in layer.parts() {
|
||||
colour = hash_bytes(colour, part.id.as_bytes());
|
||||
colour = hash_bytes(colour, part.join.name().as_bytes());
|
||||
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
|
||||
colour = mix(colour, u64::from(part.invert));
|
||||
colour = hash_bytes(colour, part.falloff.name().as_bytes());
|
||||
for v in [part.feather, part.morph_radius] {
|
||||
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
||||
}
|
||||
}
|
||||
for (op_id, param_id, value) in layer.params() {
|
||||
colour = hash_bytes(colour, op_id.as_bytes());
|
||||
@@ -1795,7 +1809,7 @@ mod tests {
|
||||
|
||||
// Moving the gradient is a different mask, so a different result.
|
||||
if let Some(layer) = g.masks_mut().get_mut("l1") {
|
||||
layer.source = MaskSource::Linear {
|
||||
layer.base_mut().source = MaskSource::Linear {
|
||||
centre: (0.2, 0.7),
|
||||
angle: 0.4,
|
||||
width: 0.2,
|
||||
|
||||
+654
-204
File diff suppressed because it is too large
Load Diff
+281
-85
@@ -69,7 +69,9 @@ use std::fmt::Write as _;
|
||||
|
||||
use crate::coverage::Coverage;
|
||||
use crate::graph::EditGraph;
|
||||
use crate::mask::{Falloff, MaskLayer, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER};
|
||||
use crate::mask::{
|
||||
Falloff, Join, MaskLayer, MaskPart, MaskSource, MaskStack, Morphology, Stroke, DEFAULT_FEATHER,
|
||||
};
|
||||
use crate::preset::Preset;
|
||||
use crate::spot::{Spot, SpotMode, SpotSet};
|
||||
use crate::state::{EditState, FilmRebake};
|
||||
@@ -833,6 +835,10 @@ impl Sidecar {
|
||||
// then simply dropped instead of corrupting whichever version
|
||||
// happened to be open.
|
||||
let mut mask: Option<PartialMask> = None;
|
||||
// Whether the `[part]` block being read names a mask that is not the
|
||||
// one above it. Its keys are dropped rather than falling through to
|
||||
// the version, where they would be read as somebody's global edit.
|
||||
let mut orphan_part = false;
|
||||
let mut masks: Vec<(String, MaskLayer)> = Vec::new();
|
||||
|
||||
for raw in lines {
|
||||
@@ -846,6 +852,7 @@ impl Sidecar {
|
||||
.and_then(|s| s.strip_suffix(']'))
|
||||
{
|
||||
masks.extend(mask.take().and_then(PartialMask::finish));
|
||||
orphan_part = false;
|
||||
if let Some(v) = current.take() {
|
||||
sidecar.put(v);
|
||||
}
|
||||
@@ -861,6 +868,7 @@ impl Sidecar {
|
||||
.and_then(|s| s.strip_suffix(']'))
|
||||
{
|
||||
masks.extend(mask.take().and_then(PartialMask::finish));
|
||||
orphan_part = false;
|
||||
match head.split_once(char::is_whitespace) {
|
||||
Some((version, id)) => {
|
||||
mask = Some(PartialMask::new(version.trim(), id.trim()));
|
||||
@@ -874,6 +882,36 @@ impl Sidecar {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A part of the mask block above this one. **Three names**, and
|
||||
// the two it repeats are checked rather than assumed: a block
|
||||
// reordered by a hand edit or by a merge would otherwise attach
|
||||
// somebody's correction to whichever mask happened to precede it,
|
||||
// which is a wrong mask rather than a missing one.
|
||||
if let Some(head) = line
|
||||
.strip_prefix("[part ")
|
||||
.and_then(|s| s.strip_suffix(']'))
|
||||
{
|
||||
let mut names = head.split_whitespace();
|
||||
orphan_part = true;
|
||||
if let (Some(version), Some(layer), Some(part)) =
|
||||
(names.next(), names.next(), names.next())
|
||||
{
|
||||
match mask.as_mut() {
|
||||
Some(m) if m.version == version && m.id == layer => {
|
||||
m.begin_part(part);
|
||||
orphan_part = false;
|
||||
}
|
||||
_ => log::warn!(
|
||||
"sidecar: part '{part}' names mask {layer} of version {version}, \
|
||||
which is not the block it follows; ignoring it"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
log::warn!("sidecar: malformed part header '{line}'; ignoring");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
// Not a key-value line and not a block header. Keep it so a
|
||||
// newer format's construct survives a round trip here.
|
||||
@@ -887,6 +925,10 @@ impl Sidecar {
|
||||
};
|
||||
let (key, value) = (key.trim(), value.trim());
|
||||
|
||||
if orphan_part {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(m) = mask.as_mut() {
|
||||
m.set(key, value);
|
||||
continue;
|
||||
@@ -1062,9 +1104,59 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
|
||||
if !layer.name.is_empty() {
|
||||
let _ = writeln!(out, "name = {}", layer.name);
|
||||
}
|
||||
let _ = writeln!(out, "source = {}", layer.source.kind());
|
||||
|
||||
match &layer.source {
|
||||
// **The base part is written into the layer's own block**, without a name
|
||||
// and without a join, and that is what makes this format change no file
|
||||
// that does not use it: a layer of one part is the same bytes it always
|
||||
// was, and a block with no `[part]` after it reads back as one part
|
||||
// (see [`PartialMask::finish`]).
|
||||
write_source(out, layer.base());
|
||||
|
||||
if layer.invert {
|
||||
let _ = writeln!(out, "invert = 1");
|
||||
}
|
||||
if layer.opacity != 1.0 {
|
||||
let _ = writeln!(out, "opacity = {}", format_value(layer.opacity));
|
||||
}
|
||||
if !layer.enabled {
|
||||
let _ = writeln!(out, "enabled = 0");
|
||||
}
|
||||
write_shaping(out, layer.base());
|
||||
for (op, param, value) in layer.params() {
|
||||
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
|
||||
}
|
||||
write_coverage(out, layer.base());
|
||||
|
||||
for part in &layer.parts()[1..] {
|
||||
write_part(out, version, &layer.id, part);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one part of a layer's mask as its own block.
|
||||
///
|
||||
/// Its own block rather than a nested key, because a merge compares lines and
|
||||
/// a part is the granularity a photographer edits: adding a correction to a
|
||||
/// mask should read, in a diff, as a correction added — not as the whole mask
|
||||
/// having been rewritten. The version and the layer are both named in the
|
||||
/// header for the reason [`write_mask`] names the version: "belongs to
|
||||
/// whatever appeared above me" is a relationship that does not survive a hand
|
||||
/// edit or a merge.
|
||||
fn write_part(out: &mut String, version: &str, layer: &str, part: &MaskPart) {
|
||||
let _ = write!(out, "\n[part {version} {layer} {}]\n", part.id);
|
||||
let _ = writeln!(out, "join = {}", part.join.name());
|
||||
write_source(out, part);
|
||||
if part.invert {
|
||||
let _ = writeln!(out, "invert = 1");
|
||||
}
|
||||
write_shaping(out, part);
|
||||
write_coverage(out, part);
|
||||
}
|
||||
|
||||
/// The `source = …` line and whatever else that kind of source needs.
|
||||
fn write_source(out: &mut String, part: &MaskPart) {
|
||||
let _ = writeln!(out, "source = {}", part.source.kind());
|
||||
|
||||
match &part.source {
|
||||
MaskSource::Regions {
|
||||
signature,
|
||||
level,
|
||||
@@ -1170,54 +1262,46 @@ fn write_mask(out: &mut String, version: &str, layer: &MaskLayer) {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if layer.invert {
|
||||
let _ = writeln!(out, "invert = 1");
|
||||
/// The edge treatment, written only where it is not the default — the same
|
||||
/// rule the parameters follow, so a file stays readable and a mask nobody has
|
||||
/// fiddled with contributes four fewer lines.
|
||||
fn write_shaping(out: &mut String, part: &MaskPart) {
|
||||
if part.feather != DEFAULT_FEATHER {
|
||||
let _ = writeln!(out, "edge-feather = {}", format_value(part.feather));
|
||||
}
|
||||
if layer.opacity != 1.0 {
|
||||
let _ = writeln!(out, "opacity = {}", format_value(layer.opacity));
|
||||
if part.falloff != Falloff::default() {
|
||||
let _ = writeln!(out, "edge-falloff = {}", part.falloff.name());
|
||||
}
|
||||
if !layer.enabled {
|
||||
let _ = writeln!(out, "enabled = 0");
|
||||
}
|
||||
// The edge treatment, written only when it is not the default — the same
|
||||
// rule the parameters follow, so a file stays readable and a mask nobody
|
||||
// has fiddled with contributes four fewer lines.
|
||||
if layer.feather != DEFAULT_FEATHER {
|
||||
let _ = writeln!(out, "edge-feather = {}", format_value(layer.feather));
|
||||
}
|
||||
if layer.falloff != Falloff::default() {
|
||||
let _ = writeln!(out, "edge-falloff = {}", layer.falloff.name());
|
||||
}
|
||||
if layer.morphology != Morphology::default() {
|
||||
let _ = writeln!(out, "morphology = {}", layer.morphology.name());
|
||||
let _ = writeln!(out, "morph-radius = {}", format_value(layer.morph_radius));
|
||||
if part.morphology != Morphology::default() {
|
||||
let _ = writeln!(out, "morphology = {}", part.morphology.name());
|
||||
let _ = writeln!(out, "morph-radius = {}", format_value(part.morph_radius));
|
||||
}
|
||||
// Absent means zero, which is the model's own weighting — so a file
|
||||
// written before this control existed reads back looking exactly as it did.
|
||||
if layer.refine != 0.0 {
|
||||
let _ = writeln!(out, "refine = {}", format_value(layer.refine));
|
||||
}
|
||||
for (op, param, value) in layer.params() {
|
||||
let _ = writeln!(out, "{op}.{param} = {}", format_value(value));
|
||||
if part.refine != 0.0 {
|
||||
let _ = writeln!(out, "refine = {}", format_value(part.refine));
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-3 | FR-CAT-8
|
||||
// The pixels a model found, so that opening the photograph again — or
|
||||
// exporting it from the grid, where no model is ever run — renders the
|
||||
// layer instead of silently dropping it. See [`crate::coverage`] for the
|
||||
// encoding and for why it is one line.
|
||||
//
|
||||
// **Last in the block, and that is on purpose.** It is thousands of
|
||||
// characters against a dozen elsewhere, and a sidecar is read by hand when
|
||||
// an edit has gone wrong (ARCH §6.12); everything a human is looking for
|
||||
// should be above it rather than after it.
|
||||
//
|
||||
// Omitted, not truncated, when it will not encode: a layer with no stored
|
||||
// coverage behaves exactly as every layer did before this existed, which
|
||||
// is a mask that needs the model run — where a *partial* one would be a
|
||||
// mask that is confidently wrong.
|
||||
if let Some(coverage) = layer.coverage.as_ref() {
|
||||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||||
/// The pixels a model found, so that opening the photograph again — or
|
||||
/// exporting it from the grid, where no model is ever run — renders the part
|
||||
/// instead of silently dropping it. See [`crate::coverage`] for the encoding
|
||||
/// and for why it is one line.
|
||||
///
|
||||
/// **Last in the block, and that is on purpose.** It is thousands of
|
||||
/// characters against a dozen elsewhere, and a sidecar is read by hand when an
|
||||
/// edit has gone wrong (ARCH §6.12); everything a human is looking for should
|
||||
/// be above it rather than after it.
|
||||
///
|
||||
/// Omitted, not truncated, when it will not encode: a part with no stored
|
||||
/// coverage behaves exactly as every layer did before this existed, which is a
|
||||
/// mask that needs the model run — where a *partial* one would be a mask that
|
||||
/// is confidently wrong.
|
||||
fn write_coverage(out: &mut String, part: &MaskPart) {
|
||||
if let Some(coverage) = part.coverage.as_ref() {
|
||||
let _ = writeln!(out, "coverage = {}", coverage.to_text());
|
||||
}
|
||||
}
|
||||
@@ -1295,15 +1379,14 @@ fn parse_stroke(value: &str) -> Option<Stroke> {
|
||||
(!stroke.is_empty()).then_some(stroke)
|
||||
}
|
||||
|
||||
/// A mask block being read, before it is complete enough to be a layer.
|
||||
/// One part of a mask being read, before it is complete enough to be a part.
|
||||
///
|
||||
/// Separate from [`MaskLayer`] because the source cannot be built until every
|
||||
/// Separate from [`MaskPart`] because the source cannot be built until every
|
||||
/// one of its fields has been seen, and the fields arrive one line at a time
|
||||
/// in whatever order the writer chose.
|
||||
struct PartialMask {
|
||||
version: String,
|
||||
struct PartialPart {
|
||||
id: String,
|
||||
name: String,
|
||||
join: Join,
|
||||
kind: String,
|
||||
signature: u64,
|
||||
level: u32,
|
||||
@@ -1321,16 +1404,14 @@ struct PartialMask {
|
||||
/// 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
|
||||
/// part, 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,
|
||||
/// The *layer's* edge transition, distinct from the radial source's own
|
||||
/// The *part's* edge transition, distinct from the radial source's own
|
||||
/// `feather` above — different quantity, different units, different key.
|
||||
edge_feather: f32,
|
||||
falloff: Falloff,
|
||||
@@ -1339,15 +1420,13 @@ struct PartialMask {
|
||||
refine: f32,
|
||||
coverage: Option<Coverage>,
|
||||
strokes: Vec<Stroke>,
|
||||
params: Vec<(String, String, f32)>,
|
||||
}
|
||||
|
||||
impl PartialMask {
|
||||
fn new(version: &str, id: &str) -> Self {
|
||||
impl PartialPart {
|
||||
fn new(id: &str) -> Self {
|
||||
Self {
|
||||
version: version.to_string(),
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
join: Join::default(),
|
||||
kind: String::new(),
|
||||
signature: 0,
|
||||
level: 0,
|
||||
@@ -1367,8 +1446,6 @@ impl PartialMask {
|
||||
band: (0.5, 1.0, crate::mask::DEFAULT_RANGE_SOFTNESS),
|
||||
hue: (0.06, 0.05),
|
||||
invert: false,
|
||||
opacity: 1.0,
|
||||
enabled: true,
|
||||
edge_feather: DEFAULT_FEATHER,
|
||||
falloff: Falloff::default(),
|
||||
morphology: Morphology::default(),
|
||||
@@ -1376,13 +1453,12 @@ impl PartialMask {
|
||||
refine: 0.0,
|
||||
coverage: None,
|
||||
strokes: Vec::new(),
|
||||
params: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&mut self, key: &str, value: &str) {
|
||||
/// Take one key, returning whether it was one of this part's.
|
||||
fn set(&mut self, key: &str, value: &str) -> bool {
|
||||
match key {
|
||||
"name" => self.name = value.to_string(),
|
||||
"source" => self.kind = value.to_string(),
|
||||
"signature" => self.signature = value.parse().unwrap_or(0),
|
||||
"level" => self.level = value.parse().unwrap_or(0),
|
||||
@@ -1434,8 +1510,6 @@ impl PartialMask {
|
||||
// and the file's line order is the order they were painted in.
|
||||
"stroke" => self.strokes.extend(parse_stroke(value)),
|
||||
"invert" => self.invert = value != "0",
|
||||
"opacity" => self.opacity = value.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
"enabled" => self.enabled = value != "0",
|
||||
// Clamped, not trusted: a feather wider than the frame is not a
|
||||
// mask, and a negative one is a distance field read backwards.
|
||||
"edge-feather" => {
|
||||
@@ -1472,19 +1546,18 @@ impl PartialMask {
|
||||
Morphology::default()
|
||||
})
|
||||
}
|
||||
_ => match (key.split_once('.'), value.parse::<f32>()) {
|
||||
(Some((op, param)), Ok(v)) if v.is_finite() => {
|
||||
self.params.push((op.to_string(), param.to_string(), v));
|
||||
}
|
||||
_ => log::warn!("sidecar: unreadable mask key {key}; ignoring"),
|
||||
},
|
||||
// Not one of ours. The caller decides what that means: an
|
||||
// adjustment's parameter inside the mask block, or a key nothing
|
||||
// in this build understands.
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Build the layer, or `None` if the source kind is one this build has
|
||||
/// Build the part, or `None` if the source kind is one this build has
|
||||
/// never heard of — a newer format's mask type, which is skipped rather
|
||||
/// than guessed at.
|
||||
fn finish(self) -> Option<(String, MaskLayer)> {
|
||||
fn finish(self) -> Option<MaskPart> {
|
||||
let source = match self.kind.as_str() {
|
||||
"regions" => MaskSource::Regions {
|
||||
signature: self.signature,
|
||||
@@ -1529,33 +1602,156 @@ impl PartialMask {
|
||||
),
|
||||
other => {
|
||||
log::warn!(
|
||||
"sidecar: unknown mask source '{other}'; skipping layer {}",
|
||||
"sidecar: unknown mask source '{other}'; skipping part {}",
|
||||
self.id
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut layer = MaskLayer::new(self.id, source);
|
||||
layer.name = self.name;
|
||||
layer.invert = self.invert;
|
||||
layer.opacity = self.opacity;
|
||||
layer.enabled = self.enabled;
|
||||
layer.feather = self.edge_feather;
|
||||
layer.falloff = self.falloff;
|
||||
layer.morphology = self.morphology;
|
||||
layer.morph_radius = self.morph_radius;
|
||||
layer.refine = self.refine;
|
||||
// Only where there is a model behind the layer to have produced it. A
|
||||
let mut part = MaskPart::new(self.id, self.join, source);
|
||||
part.invert = self.invert;
|
||||
part.feather = self.edge_feather;
|
||||
part.falloff = self.falloff;
|
||||
part.morphology = self.morphology;
|
||||
part.morph_radius = self.morph_radius;
|
||||
part.refine = self.refine;
|
||||
// Only where there is a model behind the part to have produced it. A
|
||||
// gradient or a brush that arrived carrying one is a file that has
|
||||
// been hand-edited or written by a build that means something else by
|
||||
// the key, and honouring it would upload a raster nothing samples.
|
||||
layer.coverage = match layer.source {
|
||||
part.coverage = match part.source {
|
||||
MaskSource::Subject { .. } | MaskSource::Category { .. } => {
|
||||
self.coverage.map(std::sync::Arc::new)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Some(part)
|
||||
}
|
||||
}
|
||||
|
||||
/// A mask block being read, before it is complete enough to be a layer.
|
||||
///
|
||||
/// Holds what belongs to the *layer* — its name, its inversion, its opacity,
|
||||
/// its adjustments — and one [`PartialPart`] per selection the mask is built
|
||||
/// from. `parts[0]` is filled by the mask block's own keys, which is what
|
||||
/// makes a file written before parts existed read back as a layer of one.
|
||||
struct PartialMask {
|
||||
version: String,
|
||||
id: String,
|
||||
name: String,
|
||||
invert: bool,
|
||||
opacity: f32,
|
||||
enabled: bool,
|
||||
params: Vec<(String, String, f32)>,
|
||||
parts: Vec<PartialPart>,
|
||||
/// Whether a `[part]` block is open, so `invert` can be told apart: in the
|
||||
/// mask block it flips the finished mask, and in a part block it flips
|
||||
/// that part before it is joined. Same word, two controls, and the block
|
||||
/// is the only thing that distinguishes them.
|
||||
in_part: bool,
|
||||
}
|
||||
|
||||
impl PartialMask {
|
||||
fn new(version: &str, id: &str) -> Self {
|
||||
Self {
|
||||
version: version.to_string(),
|
||||
id: id.to_string(),
|
||||
name: String::new(),
|
||||
invert: false,
|
||||
opacity: 1.0,
|
||||
enabled: true,
|
||||
params: Vec::new(),
|
||||
parts: vec![PartialPart::new(crate::mask::FIRST_PART_ID)],
|
||||
in_part: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a `[part]` block. Every key from here to the next block header
|
||||
/// belongs to it.
|
||||
fn begin_part(&mut self, id: &str) {
|
||||
self.parts.push(PartialPart::new(id));
|
||||
self.in_part = true;
|
||||
}
|
||||
|
||||
fn set(&mut self, key: &str, value: &str) {
|
||||
// The layer's own keys, and only while no part block is open. A part
|
||||
// has an `invert` of its own and no opacity at all, so which of the
|
||||
// two an `invert` line means is decided by the block it is in.
|
||||
if !self.in_part {
|
||||
match key {
|
||||
"name" => {
|
||||
self.name = value.to_string();
|
||||
return;
|
||||
}
|
||||
"invert" => {
|
||||
self.invert = value != "0";
|
||||
return;
|
||||
}
|
||||
"opacity" => {
|
||||
self.opacity = value.parse::<f32>().unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
return;
|
||||
}
|
||||
"enabled" => {
|
||||
self.enabled = value != "0";
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if key == "join" {
|
||||
// Unknown falls back to a union rather than dropping the part: a
|
||||
// join this build does not have is a part that joins some other
|
||||
// way, and adding it is the reading that keeps the selection
|
||||
// visible and therefore fixable. Silently subtracting under a name
|
||||
// nobody could see would not be.
|
||||
if let Some(part) = self.parts.last_mut() {
|
||||
part.join = Join::from_name(value).unwrap_or_else(|| {
|
||||
log::warn!("sidecar: unknown join '{value}'; adding the part instead");
|
||||
Join::Union
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(part) = self.parts.last_mut() {
|
||||
if part.set(key, value) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever is left is an adjustment, which belongs to the layer
|
||||
// however deep in the block it was written.
|
||||
match (key.split_once('.'), value.parse::<f32>()) {
|
||||
(Some((op, param)), Ok(v)) if v.is_finite() => {
|
||||
self.params.push((op.to_string(), param.to_string(), v));
|
||||
}
|
||||
_ => log::warn!("sidecar: unreadable mask key {key}; ignoring"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the layer, or `None` when its base part cannot be built — a
|
||||
/// newer format's mask type, which is skipped rather than guessed at.
|
||||
///
|
||||
/// A *later* part that cannot be built costs that part and not the layer,
|
||||
/// on the rule [`parse_stroke`] already follows: the rest of the mask is
|
||||
/// work the photographer did, and throwing it away to protect the
|
||||
/// consistency of one correction loses more than it saves.
|
||||
fn finish(self) -> Option<(String, MaskLayer)> {
|
||||
let id = self.id;
|
||||
let mut parts = Vec::new();
|
||||
for (i, part) in self.parts.into_iter().enumerate() {
|
||||
match (part.finish(), i) {
|
||||
(Some(p), _) => parts.push(p),
|
||||
(None, 0) => return None,
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut layer = MaskLayer::from_parts(id, parts)?;
|
||||
layer.name = self.name;
|
||||
layer.invert = self.invert;
|
||||
layer.opacity = self.opacity;
|
||||
layer.enabled = self.enabled;
|
||||
for (op, param, value) in &self.params {
|
||||
// `ParamId` holds a `&'static str` and this one came off disk, so
|
||||
// it is matched against the descriptors and the *static* id is
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! round-trip is not a persistence bug, it is lost work, and the tests here
|
||||
//! are about the ways that happens quietly rather than loudly.
|
||||
|
||||
use dr_pipeline::descriptor::ParamId;
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||
use dr_pipeline::descriptor::{OpId, ParamId};
|
||||
use dr_pipeline::mask::{Join, MaskLayer, MaskPart, MaskSource, Morphology};
|
||||
use dr_pipeline::{EditGraph, Sidecar, Version};
|
||||
|
||||
fn regions(ids: &[u32]) -> MaskSource {
|
||||
@@ -55,7 +55,7 @@ fn a_region_mask_survives_a_round_trip() {
|
||||
let layer = &layers[0];
|
||||
assert_eq!(layer.id, "m1");
|
||||
assert_eq!(layer.name, "Subject");
|
||||
assert_eq!(layer.source, regions(&[3, 7, 12]));
|
||||
assert_eq!(layer.base().source, regions(&[3, 7, 12]));
|
||||
assert_eq!(
|
||||
layer
|
||||
.ops
|
||||
@@ -110,8 +110,14 @@ fn gradients_keep_their_geometry() {
|
||||
let restored = round_trip(&graph);
|
||||
let layers = restored.masks().layers();
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(layers[0].source, graph.masks().layers()[0].source);
|
||||
assert_eq!(layers[1].source, graph.masks().layers()[1].source);
|
||||
assert_eq!(
|
||||
layers[0].base().source,
|
||||
graph.masks().layers()[0].base().source
|
||||
);
|
||||
assert_eq!(
|
||||
layers[1].base().source,
|
||||
graph.masks().layers()[1].base().source
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -155,7 +161,7 @@ fn region_order_is_normalised_on_read() {
|
||||
let parsed = Sidecar::parse(text).expect("parse");
|
||||
let layer = &parsed.versions["default"].masks.layers()[0];
|
||||
assert_eq!(
|
||||
layer.source,
|
||||
layer.base().source,
|
||||
MaskSource::Regions {
|
||||
signature: 5,
|
||||
level: 10,
|
||||
@@ -302,11 +308,11 @@ fn brushed(id: &str, gestures: &[Gesture]) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new(id, MaskSource::brush());
|
||||
layer.set_param("exposure", ParamId("exposure"), 1.0);
|
||||
for (erase, radius, path) in gestures {
|
||||
layer.begin_stroke(*erase, *radius, 0.5, 1.0);
|
||||
layer.begin_stroke(0, *erase, *radius, 0.5, 1.0);
|
||||
for &(x, y) in path {
|
||||
layer.extend_stroke(x, y);
|
||||
layer.extend_stroke(0, x, y);
|
||||
}
|
||||
layer.end_stroke();
|
||||
layer.end_stroke(0);
|
||||
}
|
||||
layer
|
||||
}
|
||||
@@ -440,7 +446,10 @@ fn an_unpainted_brush_layer_still_persists() {
|
||||
|
||||
let restored = round_trip(&graph);
|
||||
assert_eq!(restored.masks().len(), 1);
|
||||
assert_eq!(restored.masks().layers()[0].source, MaskSource::brush());
|
||||
assert_eq!(
|
||||
restored.masks().layers()[0].base().source,
|
||||
MaskSource::brush()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -576,10 +585,10 @@ fn show_a_sidecar() {
|
||||
);
|
||||
subject.name = "person".into();
|
||||
subject.invert = true;
|
||||
subject.feather = 0.02;
|
||||
subject.falloff = dr_pipeline::mask::Falloff::Gaussian;
|
||||
subject.morphology = dr_pipeline::mask::Morphology::Dilate;
|
||||
subject.morph_radius = 0.012;
|
||||
subject.base_mut().feather = 0.02;
|
||||
subject.base_mut().falloff = dr_pipeline::mask::Falloff::Gaussian;
|
||||
subject.base_mut().morphology = dr_pipeline::mask::Morphology::Dilate;
|
||||
subject.base_mut().morph_radius = 0.012;
|
||||
subject.set_param("saturation", ParamId("saturation"), -100.0);
|
||||
subject.set_param("exposure", ParamId("exposure"), -0.4);
|
||||
graph.masks_mut().push(subject);
|
||||
@@ -635,7 +644,7 @@ fn category_masks_keep_their_name() {
|
||||
},
|
||||
);
|
||||
sky.name = "sky".into();
|
||||
sky.feather = 0.03;
|
||||
sky.base_mut().feather = 0.03;
|
||||
sky.set_param("exposure", ParamId("exposure"), 0.5);
|
||||
graph.masks_mut().push(sky);
|
||||
|
||||
@@ -655,9 +664,15 @@ fn category_masks_keep_their_name() {
|
||||
let restored = round_trip(&graph);
|
||||
let layers = restored.masks().layers();
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(layers[0].source, graph.masks().layers()[0].source);
|
||||
assert_eq!(layers[1].source, graph.masks().layers()[1].source);
|
||||
assert!((layers[0].feather - 0.03).abs() < 1e-6);
|
||||
assert_eq!(
|
||||
layers[0].base().source,
|
||||
graph.masks().layers()[0].base().source
|
||||
);
|
||||
assert_eq!(
|
||||
layers[1].base().source,
|
||||
graph.masks().layers()[1].base().source
|
||||
);
|
||||
assert!((layers[0].base().feather - 0.03).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// The refine strictness survives, and its absence means zero.
|
||||
@@ -683,7 +698,7 @@ fn a_categorys_refine_strictness_survives_and_defaults_off() {
|
||||
name: "sky".into(),
|
||||
},
|
||||
);
|
||||
sky.refine = 3.7;
|
||||
sky.base_mut().refine = 3.7;
|
||||
graph.masks_mut().push(sky);
|
||||
|
||||
// Left at zero, so the writer omits the key entirely — which is the case
|
||||
@@ -711,12 +726,13 @@ fn a_categorys_refine_strictness_survives_and_defaults_off() {
|
||||
let layers = round_trip(&graph);
|
||||
let layers = layers.masks().layers();
|
||||
assert!(
|
||||
(layers[0].refine - 3.7).abs() < 1e-6,
|
||||
(layers[0].base().refine - 3.7).abs() < 1e-6,
|
||||
"got {}",
|
||||
layers[0].refine
|
||||
layers[0].base().refine
|
||||
);
|
||||
assert_eq!(
|
||||
layers[1].refine, 0.0,
|
||||
layers[1].base().refine,
|
||||
0.0,
|
||||
"an absent key is the model's own mask"
|
||||
);
|
||||
}
|
||||
@@ -735,7 +751,7 @@ fn an_out_of_range_refine_is_clamped() {
|
||||
name: "sky".into(),
|
||||
},
|
||||
);
|
||||
sky.refine = 3.0;
|
||||
sky.base_mut().refine = 3.0;
|
||||
graph.masks_mut().push(sky);
|
||||
|
||||
// Edited in the written text rather than assembled by hand, so this test
|
||||
@@ -754,7 +770,7 @@ fn an_out_of_range_refine_is_clamped() {
|
||||
.apply(&mut restored)
|
||||
.expect_no_film();
|
||||
|
||||
let refine = restored.masks().layers()[0].refine;
|
||||
let refine = restored.masks().layers()[0].base().refine;
|
||||
assert!(
|
||||
refine <= dr_pipeline::mask::MAX_REFINE,
|
||||
"a file asked for 900 and got {refine}"
|
||||
@@ -828,7 +844,7 @@ fn inside(values: &[u8]) -> Vec<bool> {
|
||||
fn with_coverage(id: &str, source: MaskSource, values: &[u8]) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new(id, source);
|
||||
layer.set_param("exposure", ParamId("exposure"), 0.75);
|
||||
layer.coverage = Some(Arc::new(
|
||||
layer.base_mut().coverage = Some(Arc::new(
|
||||
Coverage::encode(values, PROXY.0, PROXY.1, RENDERED_LEVELS).expect("a disc should encode"),
|
||||
));
|
||||
layer
|
||||
@@ -849,11 +865,15 @@ fn a_subjects_coverage_survives_a_round_trip() {
|
||||
let layer = &restored.masks().layers()[0];
|
||||
|
||||
assert_eq!(
|
||||
layer.source,
|
||||
layer.base().source,
|
||||
subject(0x1234, 2),
|
||||
"the identity is still there"
|
||||
);
|
||||
let coverage = layer.coverage.as_ref().expect("the pixels came back too");
|
||||
let coverage = layer
|
||||
.base()
|
||||
.coverage
|
||||
.as_ref()
|
||||
.expect("the pixels came back too");
|
||||
assert_eq!((coverage.width(), coverage.height()), PROXY);
|
||||
assert_eq!(
|
||||
inside(&coverage.decode()),
|
||||
@@ -876,9 +896,9 @@ fn a_categorys_coverage_survives_a_round_trip() {
|
||||
|
||||
let restored = round_trip(&graph);
|
||||
let layer = &restored.masks().layers()[0];
|
||||
assert_eq!(layer.source, source);
|
||||
assert_eq!(layer.base().source, source);
|
||||
assert_eq!(
|
||||
inside(&layer.coverage.as_ref().expect("coverage").decode()),
|
||||
inside(&layer.base().coverage.as_ref().expect("coverage").decode()),
|
||||
inside(&values)
|
||||
);
|
||||
}
|
||||
@@ -969,10 +989,10 @@ exposure.exposure = 0.75
|
||||
.expect_no_film();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert_eq!(layer.source, subject(4660, 2));
|
||||
assert_eq!(layer.base().source, subject(4660, 2));
|
||||
assert_eq!(layer.name, "Dog");
|
||||
assert!(
|
||||
layer.coverage.is_none(),
|
||||
layer.base().coverage.is_none(),
|
||||
"absent means absent, not an empty mask"
|
||||
);
|
||||
}
|
||||
@@ -1011,9 +1031,9 @@ coverage = 96 64 999 not-a-payload
|
||||
.expect_no_film();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert_eq!(layer.source, subject(4660, 2));
|
||||
assert_eq!(layer.base().source, subject(4660, 2));
|
||||
assert_eq!(layer.name, "Dog");
|
||||
assert!(layer.coverage.is_none());
|
||||
assert!(layer.base().coverage.is_none());
|
||||
assert_eq!(
|
||||
layer
|
||||
.ops
|
||||
@@ -1048,8 +1068,8 @@ fn coverage_is_not_loaded_onto_a_source_with_no_model_behind_it() {
|
||||
.expect_no_film();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert!(matches!(layer.source, MaskSource::Radial { .. }));
|
||||
assert!(layer.coverage.is_none());
|
||||
assert!(matches!(layer.base().source, MaskSource::Radial { .. }));
|
||||
assert!(layer.base().coverage.is_none());
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-9
|
||||
@@ -1063,9 +1083,9 @@ fn coverage_is_not_loaded_onto_a_source_with_no_model_behind_it() {
|
||||
fn a_coverage_one_device_has_and_the_other_lacks_is_not_a_conflict() {
|
||||
let layer = |with: bool| {
|
||||
let mut l = lit("m1", &[1], 1.0);
|
||||
l.source = subject(7, 0);
|
||||
l.base_mut().source = subject(7, 0);
|
||||
if with {
|
||||
l.coverage = Some(Arc::new(
|
||||
l.base_mut().coverage = Some(Arc::new(
|
||||
Coverage::encode(&a_disc(), PROXY.0, PROXY.1, RENDERED_LEVELS).expect("encode"),
|
||||
));
|
||||
}
|
||||
@@ -1088,7 +1108,12 @@ fn a_coverage_one_device_has_and_the_other_lacks_is_not_a_conflict() {
|
||||
"running the model is not an edit: {conflicts:?}"
|
||||
);
|
||||
assert!(
|
||||
ours.masks.get("m1").expect("layer").coverage.is_some(),
|
||||
ours.masks
|
||||
.get("m1")
|
||||
.expect("layer")
|
||||
.base()
|
||||
.coverage
|
||||
.is_some(),
|
||||
"and the side that has the pixels keeps them"
|
||||
);
|
||||
}
|
||||
@@ -1104,8 +1129,8 @@ fn a_real_edit_is_still_a_conflict_with_coverage_present() {
|
||||
};
|
||||
let layer = |ev: f32| {
|
||||
let mut l = lit("m1", &[1], ev);
|
||||
l.source = subject(7, 0);
|
||||
l.coverage = stored();
|
||||
l.base_mut().source = subject(7, 0);
|
||||
l.base_mut().coverage = stored();
|
||||
l
|
||||
};
|
||||
|
||||
@@ -1149,11 +1174,11 @@ fn range_masks_keep_their_bands() {
|
||||
let layers = restored.masks().layers();
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(
|
||||
layers[0].source,
|
||||
layers[0].base().source,
|
||||
MaskSource::luminance_range(0.62, 0.91, 0.2)
|
||||
);
|
||||
assert_eq!(
|
||||
layers[1].source,
|
||||
layers[1].base().source,
|
||||
MaskSource::colour_range(0.07, 0.04, 0.2, 0.85, 0.11)
|
||||
);
|
||||
}
|
||||
@@ -1200,7 +1225,7 @@ fn a_crossed_band_is_ordered_on_read() {
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(
|
||||
graph.masks().layers()[0].source,
|
||||
graph.masks().layers()[0].base().source,
|
||||
MaskSource::luminance_range(0.2, 0.9, 0.1),
|
||||
"the band came back as the one the numbers describe"
|
||||
);
|
||||
@@ -1229,5 +1254,255 @@ fn a_range_mask_with_no_band_falls_back_to_the_default_one() {
|
||||
.apply(&mut graph)
|
||||
.expect_no_film();
|
||||
|
||||
assert_eq!(graph.masks().layers()[0].source, MaskSource::highlights());
|
||||
assert_eq!(
|
||||
graph.masks().layers()[0].base().source,
|
||||
MaskSource::highlights()
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A subject with a correction painted on top of it — the ordinary shape of a
|
||||
/// mask that has been edited by hand.
|
||||
fn corrected(id: &str, join: Join) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new(
|
||||
id,
|
||||
MaskSource::Subject {
|
||||
signature: 0xdead_beef,
|
||||
index: 0,
|
||||
class: "dog".into(),
|
||||
score: 0.9,
|
||||
},
|
||||
);
|
||||
layer.set_param("exposure", ParamId("exposure"), 0.5);
|
||||
layer.push_part(MaskPart::painted(layer.next_part_id(), join));
|
||||
layer.begin_stroke(1, false, 0.05, 0.5, 1.0);
|
||||
for &(x, y) in &[(0.2, 0.2), (0.3, 0.25), (0.4, 0.3)] {
|
||||
layer.extend_stroke(1, x, y);
|
||||
}
|
||||
layer.end_stroke(1);
|
||||
layer
|
||||
}
|
||||
|
||||
/// The property that makes this format change safe: a mask nobody has
|
||||
/// corrected writes what it has always written, so every sidecar in every
|
||||
/// library keeps parsing to the same edit and no file grows a block it does
|
||||
/// not need.
|
||||
#[test]
|
||||
fn a_mask_of_one_part_writes_no_part_block() {
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(Version::from_graph(
|
||||
"default",
|
||||
"Default",
|
||||
&graph_with_mask(),
|
||||
));
|
||||
let text = sidecar.to_text();
|
||||
|
||||
assert!(text.contains("[mask default m1]"), "{text}");
|
||||
assert!(
|
||||
!text.contains("[part "),
|
||||
"a layer of one part invented a block for it:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("join ="),
|
||||
"a layer of one part wrote a join nothing joins to:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// And the other half of it: a file written before parts existed reads back as
|
||||
/// a layer of exactly one, rather than as one with an empty correction on it.
|
||||
#[test]
|
||||
fn a_mask_block_with_no_part_blocks_is_one_part() {
|
||||
let layers = round_trip(&graph_with_mask());
|
||||
let layers = layers.masks().layers();
|
||||
|
||||
assert_eq!(layers[0].parts().len(), 1);
|
||||
assert_eq!(layers[0].parts()[0].id, "p1", "the implied part is named");
|
||||
assert_eq!(layers[0].parts()[0].join, Join::Union);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_correction_painted_onto_a_subject_survives_a_round_trip() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let layer = corrected("m1", Join::Subtract);
|
||||
let expected = layer.parts()[1].strokes().to_vec();
|
||||
graph.masks_mut().push(layer);
|
||||
|
||||
let restored = round_trip(&graph);
|
||||
let layer = &restored.masks().layers()[0];
|
||||
|
||||
assert_eq!(layer.parts().len(), 2, "both parts came back");
|
||||
assert_eq!(layer.parts()[1].id, "p2");
|
||||
assert_eq!(layer.parts()[1].join, Join::Subtract);
|
||||
assert_eq!(layer.parts()[1].strokes(), expected.as_slice());
|
||||
assert_eq!(
|
||||
layer.parts()[0].source.kind(),
|
||||
"subject",
|
||||
"and the base is still what it was"
|
||||
);
|
||||
}
|
||||
|
||||
/// A part carries its own edge, which is the whole reason it is a part rather
|
||||
/// than a second source on the layer: a model's soft coverage and a stroke
|
||||
/// painted where it stopped short want different boundaries.
|
||||
#[test]
|
||||
fn each_part_keeps_its_own_shaping() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
let mut layer = corrected("m1", Join::Union);
|
||||
layer.base_mut().feather = 0.02;
|
||||
layer.base_mut().morphology = Morphology::Dilate;
|
||||
layer.base_mut().morph_radius = 0.01;
|
||||
layer.parts_mut()[1].feather = 0.0;
|
||||
layer.parts_mut()[1].invert = true;
|
||||
graph.masks_mut().push(layer);
|
||||
|
||||
let restored = round_trip(&graph);
|
||||
let layer = &restored.masks().layers()[0];
|
||||
|
||||
assert_eq!(layer.parts()[0].feather, 0.02);
|
||||
assert_eq!(layer.parts()[0].morphology, Morphology::Dilate);
|
||||
assert_eq!(layer.parts()[1].feather, 0.0);
|
||||
assert!(layer.parts()[1].invert);
|
||||
assert!(
|
||||
!layer.invert,
|
||||
"the layer's own inversion is a different one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_a_corrected_mask_twice_is_byte_identical() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.masks_mut().push(corrected("m1", Join::Subtract));
|
||||
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(Version::from_graph("default", "Default", &graph));
|
||||
let first = sidecar.to_text();
|
||||
let second = Sidecar::parse(&first).expect("reparse").to_text();
|
||||
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
/// A part block that names another mask is somebody's correction attached to
|
||||
/// the wrong photograph if it is honoured. Dropped — and, more importantly,
|
||||
/// its keys must not fall through to the version, where a `stroke` line would
|
||||
/// be a global edit nobody made.
|
||||
#[test]
|
||||
fn a_part_naming_a_different_mask_is_dropped() {
|
||||
let text = "drsc 1\n\
|
||||
\n[version default]\n\
|
||||
name = Default\n\
|
||||
exposure.exposure = 0.25\n\
|
||||
\n[mask default m1]\n\
|
||||
source = brush\n\
|
||||
exposure.exposure = 1\n\
|
||||
\n[part default m9 p2]\n\
|
||||
join = subtract\n\
|
||||
source = brush\n\
|
||||
stroke = add 0.05 0.5 1 0.2,0.2 0.3,0.3\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();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert_eq!(layer.parts().len(), 1, "the orphan part was not attached");
|
||||
assert_eq!(
|
||||
graph.param(OpId("exposure"), ParamId("exposure")),
|
||||
Some(0.25),
|
||||
"and its keys did not land on the version"
|
||||
);
|
||||
}
|
||||
|
||||
/// The rule a malformed stroke already follows, one level up: a part this
|
||||
/// build cannot read costs that part, and the rest of the mask — which is work
|
||||
/// somebody did — survives.
|
||||
#[test]
|
||||
fn a_part_with_an_unknown_source_costs_only_that_part() {
|
||||
let text = "drsc 1\n\
|
||||
\n[version default]\n\
|
||||
name = Default\n\
|
||||
\n[mask default m1]\n\
|
||||
source = brush\n\
|
||||
stroke = add 0.05 0.5 1 0.1,0.1 0.2,0.2\n\
|
||||
exposure.exposure = 1\n\
|
||||
\n[part default m1 p2]\n\
|
||||
join = union\n\
|
||||
source = hologram\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();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert_eq!(layer.parts().len(), 1);
|
||||
assert_eq!(layer.strokes().len(), 1, "the base kept its stroke");
|
||||
}
|
||||
|
||||
/// An unknown join adds, rather than dropping the part. A selection that is
|
||||
/// visible can be fixed by the photographer; one that quietly took pixels away
|
||||
/// under a name this build could not read cannot be.
|
||||
#[test]
|
||||
fn an_unknown_join_adds_the_part() {
|
||||
let text = "drsc 1\n\
|
||||
\n[version default]\n\
|
||||
name = Default\n\
|
||||
\n[mask default m1]\n\
|
||||
source = brush\n\
|
||||
exposure.exposure = 1\n\
|
||||
\n[part default m1 p2]\n\
|
||||
join = xor\n\
|
||||
source = brush\n\
|
||||
stroke = add 0.05 0.5 1 0.2,0.2 0.3,0.3\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();
|
||||
|
||||
let layer = &graph.masks().layers()[0];
|
||||
assert_eq!(layer.parts().len(), 2);
|
||||
assert_eq!(layer.parts()[1].join, Join::Union);
|
||||
}
|
||||
|
||||
/// Two devices, one correcting a mask and one leaving it alone: the correction
|
||||
/// arrives, because a layer merges as a whole and the part travelled with it.
|
||||
#[test]
|
||||
fn a_correction_made_on_one_device_reaches_the_other() {
|
||||
let base = version_with("default", 1, |g| {
|
||||
g.masks_mut().push(lit("m1", &[1, 2], 0.5));
|
||||
});
|
||||
let mut ours = base.clone();
|
||||
ours.revision = 2;
|
||||
let mut theirs = base.clone();
|
||||
theirs.revision = 3;
|
||||
if let Some(layer) = theirs.masks.get_mut("m1") {
|
||||
layer.push_part(MaskPart::painted("p2", Join::Subtract));
|
||||
layer.begin_stroke(1, false, 0.05, 0.5, 1.0);
|
||||
layer.extend_stroke(1, 0.2, 0.2);
|
||||
layer.extend_stroke(1, 0.4, 0.4);
|
||||
layer.end_stroke(1);
|
||||
}
|
||||
|
||||
let conflicts = ours.merge(&theirs, Some(&base));
|
||||
|
||||
assert!(conflicts.is_empty(), "only one side changed the layer");
|
||||
let layer = ours.masks.get("m1").expect("layer");
|
||||
assert_eq!(layer.parts().len(), 2);
|
||||
assert_eq!(layer.parts()[1].join, Join::Subtract);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user