Files
lightcraft/crates/engine/src/cmd/masks.rs
T
Matej Vrzala dadb0dfc79 Colour-range mask: cap the number of samples from automation
A colour-range mask compares every pixel against every sample, so
render and export cost is pixels x samples. The interactive sampler
stops at 5, but mask.add / mask.update (colorRange) passed their
`samples` array straight through, so a few hundred samples on a large
render turned into billions of operations and hung rendering and
export. The project already caps similar lists (MAX_STROKE_DABS,
MAX_DETAIL).

The samples list from automation is now capped at 64.

Test: a request with 1000 samples is cut to 64; a two-sample request is
unchanged.

Reported-by: BS.ehlas.cz
2026-10-23 09:46:14 +02:00

1155 lines
57 KiB
Rust

//! Masking and Remove (spot) commands on the active photo.
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode};
use lightcraft_geom::Point;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_param};
use crate::segment::{Click, MAX_CLICKS};
use crate::{Result, Session};
/// A colour-range mask compares every pixel against every sample, so render and export cost is
/// pixels x samples. The interactive sampler stops at 5; automation (`mask.add`/`mask.update`)
/// passed its `samples` array straight through, so a few hundred samples on a large render hung.
const MAX_COLOR_RANGE_SAMPLES: usize = 64;
fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
Ok(match kind {
"brush" => MaskShape::Brush { strokes: vec![] },
"linear" => {
MaskShape::Linear { start: point(p, "start").unwrap_or(Point::new(0.5, 0.25)), end: point(p, "end").unwrap_or(Point::new(0.5, 0.6)) }
}
"radial" => MaskShape::Radial {
center: point(p, "center").unwrap_or(Point::new(0.5, 0.5)),
rx: f64_or(p, "rx", 0.22),
ry: f64_or(p, "ry", 0.16),
angle: f64_or(p, "angle", 0.0),
feather: f64_or(p, "feather", 50.0),
invert: bool_or(p, "invert", false),
},
"sky" => MaskShape::Sky,
"subject" => MaskShape::Subject,
"background" => MaskShape::Background,
"luminanceRange" => MaskShape::LuminanceRange {
lo: f64_or(p, "lo", 0.6),
hi: f64_or(p, "hi", 1.0),
lo_feather: f64_or(p, "loFeather", 0.1),
hi_feather: f64_or(p, "hiFeather", 0.1),
},
"colorRange" => {
let samples: Vec<[f64; 3]> = p
.get("samples")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|s| Some([s.get(0)?.as_f64()?, s.get(1)?.as_f64()?, s.get(2)?.as_f64()?]))
.take(MAX_COLOR_RANGE_SAMPLES)
.collect()
})
.unwrap_or_default();
MaskShape::ColorRange { samples, refine: f64_or(p, "refine", 50.0) }
}
"object" => MaskShape::Object { hint: points(p, "points"), exclude: points(p, "exclude"), seg: seg_param(p), detail: vec![], edge: 0.0 },
"prompt" => {
MaskShape::Prompt { text: str_param(p, "text").unwrap_or_default().trim().to_string(), seg: seg_param(p), detail: vec![], edge: 0.0 }
}
other => {
return Err(bad(
c,
format!("unknown mask kind `{other}` (brush|linear|radial|sky|subject|background|luminanceRange|colorRange|object|prompt)"),
));
}
})
}
/// `[[x, y], …]` normalized points under `key`.
fn points(p: &Value, key: &str) -> Vec<Point> {
p.get(key)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect())
.unwrap_or_default()
}
/// A segmentation passed in (`seg`: a stored or replayed AI mask; the model isn't needed).
fn seg_param(p: &Value) -> Option<lightcraft_develop::SegMask> {
p.get("seg").and_then(|v| serde_json::from_value(v.clone()).ok())
}
/// What a new AI shape still needs once it is added.
enum Later {
Nothing,
/// Background mode: these Object clicks are segmented on the worker.
Clicks(Vec<Point>, Vec<Point>),
/// Background mode: the Describe selection is computed first; the mask is added when found.
Text(String),
}
/// Compute the segmentation of a new AI shape (Object with clicks, Prompt) — or, in background
/// mode, say what to queue. An Object without clicks stays empty until the photo is clicked
/// (the photo is analyzed meanwhile); a Prompt that matches nothing is an error. Shapes given
/// with their `seg` need no model.
fn resolve_ai(s: &mut Session, shape: &mut MaskShape, c: &str) -> Result<Later> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let background = s.segmenter.background;
match shape {
MaskShape::Object { seg: Some(_), .. } | MaskShape::Prompt { seg: Some(_), .. } => {}
MaskShape::Object { hint, exclude, .. } if !hint.is_empty() && background => {
s.segmenter.require_model()?;
return Ok(Later::Clicks(std::mem::take(hint), std::mem::take(exclude)));
}
MaskShape::Object { hint, exclude, seg, .. } if !hint.is_empty() => {
*seg = Some(segment(s, |s| s.segment_clicks(id, &clicks_of(hint, exclude)))?);
}
MaskShape::Object { .. } => {
// the clicks come later: refuse an Object mask that could never be computed (no
// model) rather than leave an empty one; in the app, analyze the photo meanwhile
s.segmenter.require_model()?;
if background {
segment(s, |s| s.segment_prepare(id))?;
}
}
MaskShape::Prompt { text, .. } if background => {
s.segmenter.require_model()?;
if text.trim().is_empty() {
return Err(ai_err("describe what to select"));
}
return Ok(Later::Text(text.clone()));
}
MaskShape::Prompt { text, seg, .. } => {
let found = segment(s, |s| s.segment_text(id, text))?;
*seg = Some(found.ok_or_else(|| ai_err(format!("nothing matching “{text}” was found in this photo")))?);
}
_ => {}
}
Ok(Later::Nothing)
}
/// Queue what a new AI component (component `comp` of mask `mask`) still needs.
fn queue_later(s: &mut Session, later: Later, mask: u32, comp: usize, mut out: Value) -> Result<Value> {
if let Later::Clicks(hint, exclude) = later {
let id = s.active().ok_or_else(|| ai_err("no active photo"))?;
segment(s, |s| s.segment_clicks_later(id, mask, comp, hint, exclude))?;
out["pending"] = json!(true);
}
Ok(out)
}
/// AI mask failures are shown as they are (not as "invalid parameters").
fn ai_err(msg: impl Into<String>) -> crate::EngineError {
crate::EngineError::Other(msg.into())
}
/// A segmentation call: the model is checked first, so a missing one is [`crate::EngineError::ModelMissing`]
/// (the segmenter's own errors are text).
fn segment<T>(s: &mut Session, f: impl FnOnce(&mut Session) -> std::result::Result<T, String>) -> Result<T> {
s.segmenter.require_model()?;
f(s).map_err(ai_err)
}
pub(crate) fn clicks_of(include: &[Point], exclude: &[Point]) -> Vec<Click> {
include.iter().map(|p| Click { at: *p, include: true }).chain(exclude.iter().map(|p| Click { at: *p, include: false })).collect()
}
fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mask>, &mut Option<u32>) -> Result<()>) -> Result<Value> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
let mut active = s.active_mask;
f(&mut d.masks, &mut active)?;
s.set_develop(id, d, label)?;
s.active_mask = active;
Ok(json!({"activeMask": active}))
}
/// Most pieces a tapered ⇧-click segment is cut into.
const MAX_TAPER_STEPS: usize = 64;
/// The strokes a ⇧-click adds: `to` drawn on from point `from`, the last point of stroke `last` (a straight segment
/// to `to`'s first point, then the rest of `to`). When the brush's size, feather or flow changed since `last`, the
/// segment is cut into pieces whose values step from `last`'s to `to`'s, so the line tapers; strokes combine by their
/// maximum, so the pieces join without building up.
fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> {
let Some(&first) = to.points.first() else { return vec![to] };
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9;
// pen pressure along the line: the last dab's at its start, the click's at its end (none when neither has any)
let pressured = !last.pressure.is_empty() || !to.pressure.is_empty();
let (p_from, p_to) = (last.pressure_at(0), to.pressure_at(0));
if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) {
let mut points = Vec::with_capacity(to.points.len() + 1);
points.push(from);
points.extend(to.points.iter().copied());
let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() };
return vec![BrushStroke { points, pressure, ..to }];
}
// about 5% of size, or 2 points of feather or flow, per piece
let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 };
let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0]
.into_iter()
.filter(|v| v.is_finite())
.fold(1.0f64, f64::max);
let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS);
let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t;
let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t));
let mut out: Vec<BrushStroke> = (0..steps)
.map(|i| {
let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64);
let p = |t: f64| (f64::from(p_from) + (f64::from(p_to) - f64::from(p_from)) * t) as f32;
BrushStroke {
points: vec![at(t0), at(t1)],
size: lerp(last.size, to.size, t1),
feather: lerp(last.feather, to.feather, t1),
flow: lerp(last.flow, to.flow, t1),
pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() },
..to.clone()
}
})
.collect();
if to.points.len() > 1 {
out.push(to);
}
out
}
pub(super) fn parse_mask_id(v: &Value, c: &str) -> Result<u32> {
v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(c, "`id` must be a non-negative integer that fits in u32"))
}
/// Omitted or null targets keep the command's default; malformed explicit targets never do.
fn optional_mask_id(p: &Value, c: &str) -> Result<Option<u32>> {
p.get("id").filter(|v| !v.is_null()).map(|v| parse_mask_id(v, c)).transpose()
}
pub(super) fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
optional_mask_id(p, c)?.or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
}
fn optional_index(p: &Value, key: &str, c: &str) -> Result<Option<usize>> {
p.get(key)
.filter(|v| !v.is_null())
.map(|v| {
v.as_u64()
.and_then(|v| usize::try_from(v).ok())
.ok_or_else(|| bad(c, format!("`{key}` must be a non-negative integer that fits in usize")))
})
.transpose()
}
fn find(masks: &mut [Mask], id: u32, c: &str) -> Result<usize> {
masks.iter().position(|m| m.id == id).ok_or_else(|| bad(c, format!("no mask {id}")))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"mask.add",
"Create New Mask",
[],
None,
"{kind: brush|linear|radial|sky|subject|background|luminanceRange|colorRange|object|prompt, ...shape params (start/end, center/rx/ry/angle/feather, lo/hi…; object: points/exclude [[x,y],…]; prompt: text; object/prompt: seg? a stored segmentation, no model needed), name?} — AI kinds need the SAM 3 model; in the app a prompt returns {pending} and the mask appears when found",
has_active,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("radial").to_string();
let mut shape = shape_from(&kind, p, "mask.add")?;
let later = resolve_ai(s, &mut shape, "mask.add")?;
let name = str_param(p, "name").map(str::to_string);
if let Later::Text(text) = &later {
let id = s.active().ok_or_else(|| bad("mask.add", "no active photo"))?;
segment(s, |s| s.segment_text_later(id, None, "add", name, text))?;
return Ok(json!({"pending": true}));
}
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
// the effect last applied to a mask of this kind
let adjust = super::mask_effects::new_mask_adjust(s, &kind);
let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| {
masks.push(Mask {
id: next,
name: name.unwrap_or_else(|| format!("Mask {next}")),
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
adjust,
..Default::default()
});
*active = Some(next);
Ok(())
})?;
queue_later(s, later, next, 0, out)
}
),
cmd!(
"mask.addComponent",
"Add/Subtract/Intersect Mask",
[],
None,
"{id?: maskId, op: add|subtract|intersect, kind, ...shape params}",
has_active,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("brush").to_string();
let op: MaskOp =
serde_json::from_value(p.get("op").cloned().unwrap_or(json!("add"))).map_err(|e| bad("mask.addComponent", e.to_string()))?;
let active = s.active_mask;
let mid = mask_id(p, active, "mask.addComponent")?;
let id = s.active().ok_or_else(|| bad("mask.addComponent", "no active photo"))?;
if !s.develop_of(id).is_some_and(|d| d.masks.iter().any(|m| m.id == mid)) {
return Err(bad("mask.addComponent", format!("no mask {mid}")));
}
let mut shape = shape_from(&kind, p, "mask.addComponent")?;
let later = resolve_ai(s, &mut shape, "mask.addComponent")?;
if let Later::Text(text) = &later {
let op = p.get("op").and_then(Value::as_str).unwrap_or("add");
segment(s, |s| s.segment_text_later(id, Some(mid), op, None, text))?;
return Ok(json!({"pending": true}));
}
let mut comp = 0;
let out = masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.addComponent")?;
masks[i].components.push(MaskComponent { name: None, op, invert: bool_or(p, "invert", false), shape });
comp = masks[i].components.len() - 1;
Ok(())
})?;
queue_later(s, later, mid, comp, out)
}
),
cmd!(
"mask.brushStroke",
"Brush Stroke",
[],
None,
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}",
has_active,
|s, p| {
let pts: Vec<Point> = p
.get("points")
.and_then(Value::as_array)
.ok_or_else(|| bad("mask.brushStroke", "missing points"))?
.iter()
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?)))
.collect();
let pts_len = pts.len();
let d = BrushStroke::default();
let stroke = BrushStroke {
points: pts,
size: f64_or(p, "size", d.size),
feather: f64_or(p, "feather", d.feather),
flow: f64_or(p, "flow", d.flow),
density: f64_or(p, "density", d.density),
erase: bool_or(p, "erase", false),
auto_mask: bool_or(p, "autoMask", false),
pressure: pressure_list(p.get("pressure"), pts_len)?,
};
let connect = bool_or(p, "connect", false);
let mut connected = false;
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
let want = optional_mask_id(p, "mask.brushStroke")?.or(s.active_mask);
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
Some(i) => i,
None => {
masks.push(Mask { id: next, name: format!("Mask {next}"), adjust, ..Default::default() });
*active = Some(next);
masks.len() - 1
}
};
let m = &mut masks[i];
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
// the previous stroke's end and its brush values (not its points)
let anchor = strokes.last().filter(|_| connect).and_then(|l| {
Some((
*l.points.last()?,
BrushStroke {
points: Vec::new(),
size: l.size,
feather: l.feather,
flow: l.flow,
// the pressure the last dab was painted with, when the stroke recorded any
pressure: if l.pressure.is_empty() { Vec::new() } else { vec![l.pressure_at(l.points.len().saturating_sub(1))] },
..BrushStroke::default()
},
))
});
match anchor {
Some((from, last)) => {
strokes.extend(connected_strokes(from, &last, stroke));
connected = true;
}
None => strokes.push(stroke),
}
} else {
m.components.push(MaskComponent {
name: None,
op: MaskOp::Add,
invert: false,
shape: MaskShape::Brush { strokes: vec![stroke] },
});
}
Ok(())
})?;
if let Some(o) = out.as_object_mut() {
o.insert("connected".into(), json!(connected));
}
Ok(out)
}
),
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
let shape: MaskShape =
serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?;
let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0);
let mid = mask_id(p, s.active_mask, "mask.update")?;
masks_edit(s, "mask.update", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.update")?;
let c = masks[i].components.get_mut(comp).ok_or_else(|| bad("mask.update", "no such component"))?;
c.shape = shape;
Ok(())
})
}),
cmd!(
"mask.sampleColor",
"Sample Mask Color",
[],
None,
"{x, y: normalized image point, add?: bool (⇧: add to the samples, max 5), id?, component?} — the colour range of the selected mask (its first colour-range component) samples the colour there → {samples}",
has_active,
|s, p| {
let c = "mask.sampleColor";
let mid = mask_id(p, s.active_mask, c)?;
let comp = optional_index(p, "component", c)?;
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let src = s.source_now(id, crate::media::SourceLevel::Thumb).map_err(|e| bad(c, e))?;
let info = s.source_info(id);
let d = s.develop_of(id).unwrap_or_default();
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
let mut out = Vec::new();
masks_edit(s, c, "Color Range", |masks, _| {
let i = find(masks, mid, c)?;
let k = comp
.or_else(|| masks[i].components.iter().position(|x| matches!(x.shape, MaskShape::ColorRange { .. })))
.ok_or_else(|| bad(c, "the mask has no colour range"))?;
let Some(MaskShape::ColorRange { samples, .. }) = masks[i].components.get_mut(k).map(|x| &mut x.shape) else {
return Err(bad(c, "not a colour range component"));
};
if !add {
samples.clear();
}
if samples.len() < 5 {
samples.push(lab);
}
out = samples.clone();
Ok(())
})?;
Ok(json!({"samples": out}))
}
),
cmd!(
"mask.objectPoint",
"Add Object Click",
[],
None,
"{x, y: normalized image point, exclude?: bool (⌥-click: leave this part out), id?: maskId} — a click on the selected mask's Object selection (its last Object component); SAM 3 re-segments the object → {include, exclude}",
has_active,
|s, p| {
let c = "mask.objectPoint";
let at = Point::new(super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
if !(0.0..=1.0).contains(&at.x) || !(0.0..=1.0).contains(&at.y) {
return Err(bad(c, "the point is outside the photo"));
}
let exclude = bool_or(p, "exclude", false);
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mid = mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
let k = m
.components
.iter()
.rposition(|x| matches!(x.shape, MaskShape::Object { .. }))
.ok_or_else(|| bad(c, "the mask has no Object selection (add one first)"))?;
let Some(MaskShape::Object { hint, exclude: ex, edge, .. }) = m.components.get(k).map(|x| &x.shape) else {
return Err(bad(c, "not an Object component"));
};
let (mut hint, mut ex, edge) = (hint.clone(), ex.clone(), *edge);
// clicks still on their way to the model count too
if let Some(pending) = s.segmenter.pending_clicks().filter(|q| (q.photo, q.mask, q.comp) == (id, mid, k)) {
(hint, ex) = (pending.hint.clone(), pending.exclude.clone());
}
if hint.len() + ex.len() >= MAX_CLICKS {
return Err(ai_err(format!("an Object selection takes at most {MAX_CLICKS} clicks; start a new one")));
}
if exclude {
ex.push(at)
} else {
hint.push(at)
}
if s.segmenter.background && !hint.is_empty() {
let out = json!({"include": hint.len(), "exclude": ex.len(), "pending": true});
segment(s, |s| s.segment_clicks_later(id, mid, k, hint, ex))?;
return Ok(out);
}
let seg = if hint.is_empty() { None } else { Some(segment(s, |s| s.segment_clicks(id, &clicks_of(&hint, &ex)))?) };
let out = json!({"include": hint.len(), "exclude": ex.len()});
masks_edit(s, c, "Object Mask", |masks, _| {
let i = find(masks, mid, c)?;
if let Some(comp) = masks[i].components.get_mut(k) {
comp.shape = MaskShape::Object { hint, exclude: ex, seg, detail: vec![], edge };
}
Ok(())
})?;
Ok(out)
}
),
cmd!(
"mask.refineDetail",
"Refine AI Mask Detail",
[],
None,
"{id?: maskId, component?} — a zoomed-in SAM 3 pass over an Object/Describe selection (default: the mask's last one): the photo around it is analyzed again at a higher resolution, in the background; the mask updates when it's done → {started}",
has_active,
|s, p| {
let c = "mask.refineDetail";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mid = mask_id(p, s.active_mask, c)?;
let d = s.develop_of(id).unwrap_or_default();
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
let k = match optional_index(p, "component", c)? {
Some(k) => k,
None => m
.components
.iter()
.rposition(|x| matches!(x.shape, MaskShape::Object { .. } | MaskShape::Prompt { .. }))
.ok_or_else(|| bad(c, "the mask has no Object or Describe selection"))?,
};
let started = segment(s, |s| s.segment_detail(id, mid, k))?;
Ok(json!({"started": started}))
}
),
cmd!(
query "segment.prepare",
"Prepare AI Masks",
[],
None,
"{} — load SAM 3 and analyze the active photo in the background, so Object clicks are instant → {busy}",
has_active,
|s, _| {
let c = "segment.prepare";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
segment(s, |s| s.segment_prepare(id))?;
Ok(json!({"busy": s.segmenter.busy()}))
}
),
cmd!(
query "segment.model.status",
"AI Mask Model Status",
[],
None,
"{} — whether this build has AI masks, whether the SAM 3 model is installed (and where), loaded, busy, and the download's progress → {available, installed, dir, loaded, busy, analyzing, sizeBytes, license, licenseUrl, mirrors, download: {running, done, total, file, error, finished}}",
super::always,
|s, _| {
let g = &s.segmenter;
Ok(json!({
"available": crate::segment::Segmenter::AVAILABLE,
"installed": g.installed(),
"dir": g.dir.as_ref().map(|d| d.display().to_string()),
"remote": g.remote_endpoint(),
"loaded": g.loaded(),
"busy": g.busy(),
"analyzing": g.analyzing(),
"sizeBytes": crate::segment::MODEL_BYTES,
"license": crate::segment::LICENSE_NAME,
"licenseUrl": crate::segment::LICENSE_URL,
"mirrors": g.mirrors().len(),
"download": g.download_status(),
}))
}
),
cmd!(
query "segment.model.download",
"Download AI Mask Model",
[],
None,
"{acknowledged: true} — download the SAM 3 model (about 3.4 GB, Meta's SAM License, not LightCraft's) in the background, from the configured mirrors; only after the user agreed to it. Watch segment.model.status; segment.model.cancel stops it (it resumes later) → {started, installed, downloading}",
super::always,
|s, p| {
let c = "segment.model.download";
if p.get("acknowledged").and_then(Value::as_bool) != Some(true) {
return Err(bad(
c,
format!(
"the SAM 3 model is a {:.1} GB download under Meta's {} ({}): pass `acknowledged: true` once the user has agreed to download it",
crate::segment::MODEL_BYTES as f64 / 1e9,
crate::segment::LICENSE_NAME,
crate::segment::LICENSE_URL
),
));
}
let started = s.segmenter.start_download(&s.activity).map_err(ai_err)?;
Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running}))
}
),
cmd!(
query "segment.model.cancel",
"Cancel AI Mask Model Download",
[],
None,
"{} — stop the SAM 3 download (what has arrived is kept, and a new download resumes from it) → {cancelled}",
super::always,
|s, _| Ok(json!({"cancelled": s.segmenter.cancel_download()}))
),
cmd!(
"mask.refine",
"Refine Mask Edges",
[],
None,
"{id?, value: 0..100} — the mask's edges snap to the photo's (guided filter; rendered on the CPU)",
has_active,
|s, p| {
let v = f64_or(p, "value", 0.0).clamp(0.0, 100.0);
let mid = mask_id(p, s.active_mask, "mask.refine")?;
masks_edit(s, "mask.refine", "Refine Edges", |masks, _| {
let i = find(masks, mid, "mask.refine")?;
masks[i].refine = v;
Ok(())
})
}
),
cmd!(
"mask.adjust",
"Set Mask Adjustments",
[],
None,
"{id?, values: {exposure, contrast, highlights, shadows, whites, blacks, temp, tint, texture, clarity, dehaze, hue, saturation, sharpness, noise, moire, defringe, color_hue, color_sat, amount}}",
has_active,
|s, p| {
let vals = p.get("values").cloned().ok_or_else(|| bad("mask.adjust", "missing values"))?;
let mid = mask_id(p, s.active_mask, "mask.adjust")?;
masks_edit(s, "mask.adjust", "Mask Adjustment", |masks, _| {
let i = find(masks, mid, "mask.adjust")?;
let mut v = serde_json::to_value(masks[i].adjust).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, &vals);
let a: LocalAdjustments = serde_json::from_value(v).map_err(|e| bad("mask.adjust", e.to_string()))?;
masks[i].adjust = clamp_local(a);
Ok(())
})
}
),
cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| {
s.active_mask = optional_mask_id(p, "mask.select")?;
ok()
}),
cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.delete")?;
masks_edit(s, "mask.delete", "Delete Mask", |masks, active| {
let i = find(masks, mid, "mask.delete")?;
masks.remove(i);
*active = masks.last().map(|m| m.id);
Ok(())
})
}),
cmd!("mask.deleteAll", "Delete All Masks", [], None, "{}", has_active, |s, _| {
masks_edit(s, "mask.deleteAll", "Delete All Masks", |masks, active| {
masks.clear();
*active = None;
Ok(())
})
}),
cmd!("mask.rename", "Rename Mask", [], None, "{id?, name}", has_active, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("mask.rename", "missing name"))?.to_string();
let mid = mask_id(p, s.active_mask, "mask.rename")?;
masks_edit(s, "mask.rename", "Rename Mask", |masks, _| {
let i = find(masks, mid, "mask.rename")?;
masks[i].name = name;
Ok(())
})
}),
cmd!("mask.invert", "Invert Mask", [], None, "{id?}", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.invert")?;
masks_edit(s, "mask.invert", "Invert Mask", |masks, _| {
let i = find(masks, mid, "mask.invert")?;
masks[i].invert = !masks[i].invert;
Ok(())
})
}),
cmd!("mask.visible", "Show/Hide Mask", [], None, "{id?, visible?: bool}", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.visible")?;
masks_edit(s, "mask.visible", "Toggle Mask", |masks, _| {
let i = find(masks, mid, "mask.visible")?;
masks[i].visible = bool_or(p, "visible", !masks[i].visible);
Ok(())
})
}),
cmd!(
"mask.duplicate",
"Duplicate Mask",
[],
None,
"{id?, invert?: bool (Duplicate and Invert)} — the copy goes right after the original and is selected",
has_active,
|s, p| {
let mid = mask_id(p, s.active_mask, "mask.duplicate")?;
let invert = bool_or(p, "invert", false);
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
let label = if invert { "Duplicate and Invert Mask" } else { "Duplicate Mask" };
masks_edit(s, "mask.duplicate", label, |masks, active| {
let i = find(masks, mid, "mask.duplicate")?;
let mut m = masks[i].clone();
m.id = next;
m.name = format!("{} copy", m.name);
if invert {
m.invert = !m.invert;
}
masks.insert(i + 1, m);
*active = Some(next);
Ok(())
})
}
),
cmd!(
"mask.component",
"Edit Mask Component",
[],
None,
"{id?, component: index, action: invert|duplicate|delete|rename|op, name? (rename; empty clears), op?: add|subtract|intersect} — one component of a mask (deleting the last one deletes the mask)",
has_active,
|s, p| {
let c = "mask.component";
let mid = mask_id(p, s.active_mask, c)?;
let k = optional_index(p, "component", c)?.ok_or_else(|| bad(c, "missing component"))?;
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
let op: Option<MaskOp> = match p.get("op") {
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
None => None,
};
let label = match action.as_str() {
"invert" => "Invert Component",
"duplicate" => "Duplicate Component",
"delete" => "Delete Component",
"rename" => "Rename Component",
"op" => "Change Component Mode",
a => return Err(bad(c, format!("unknown action {a:?} (invert|duplicate|delete|rename|op)"))),
};
masks_edit(s, c, label, |masks, active| {
let i = find(masks, mid, c)?;
let comps = &mut masks[i].components;
let comp = comps.get_mut(k).ok_or_else(|| bad(c, "no such component"))?;
match action.as_str() {
"invert" => comp.invert = !comp.invert,
"rename" => comp.name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string),
"op" => comp.op = op.ok_or_else(|| bad(c, "missing op"))?,
"duplicate" => {
let copy = comp.clone();
comps.insert(k + 1, copy);
}
_ => {
comps.remove(k);
if comps.is_empty() {
masks.remove(i);
*active = masks.last().map(|m| m.id);
}
}
}
Ok(())
})
}
),
cmd!("mask.move", "Move Mask", [], None, "{id?, to?: index (0 = top), delta?: ±n} — reorder the masks list", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.move")?;
let mut masks = s.active().and_then(|id| s.develop_of(id)).map(|d| d.masks.clone()).unwrap_or_default();
let i = find(&mut masks, mid, "mask.move")?;
let to = match (p.get("to").and_then(Value::as_i64), p.get("delta").and_then(Value::as_i64)) {
(Some(t), _) => t,
// (an agent's delta can be anything: saturate rather than overflow)
(None, Some(d)) => (i as i64).saturating_add(d),
(None, None) => return Err(bad("mask.move", "give `to` (index) or `delta`")),
}
.clamp(0, masks.len() as i64 - 1) as usize;
if to == i {
return Ok(json!({"activeMask": s.active_mask}));
}
masks_edit(s, "mask.move", "Reorder Masks", |masks, _| {
let i = find(masks, mid, "mask.move")?;
let m = masks.remove(i);
masks.insert(to, m);
Ok(())
})
}),
// ---- Remove tool (spots)
cmd!(
"spot.findDust",
"Find Dust Spots",
[],
None,
"{sensitivity?: 0..100 (50), add?: bool (true)} — find sensor-dust spots (small soft dark spots on smooth areas) and add a heal spot on each (one undo step) → {spots: [{x, y, size}], added}",
has_active,
|s, p| {
let c = "spot.findDust";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
let found = lightcraft_pipeline::dust::detect(&img, f64_or(p, "sensitivity", 50.0) as f32);
let out: Vec<Value> = found.iter().map(|d| json!({"x": d.x, "y": d.y, "size": d.radius})).collect();
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
return Ok(json!({"spots": out, "added": 0}));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let base = Spot::default();
for d in &found {
let mut spot = Spot {
mode: SpotMode::Heal,
points: vec![Point::new(d.x, d.y)],
size: d.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: base.feather,
opacity: 100.0,
source_offset: None,
};
spot.source_offset = pick_source(s, id, &dd, &spot, None);
dd.spots.push(spot);
}
s.set_develop(id, dd, "Find Dust Spots")?;
Ok(json!({"spots": out, "added": found.len()}))
}
),
cmd!(
"spot.findBlemishes",
"Find Blemishes",
[],
None,
"{sensitivity?: 0..100 (50), add?: bool (true), maxPerFace?: 1..20 (6)} — find small blemishes (darker or redder round spots on skin) on the faces of the active photo and add a heal spot on each (one undo step); needs the YuNet face detector (`faces.models.download {id: \"yunet-2023mar\"}`). Faces narrower than 160 px at 1600 px, and faces with more than 12 candidates (stubble, freckles, pores), get none → {faces, spots: [{x, y, size, face}], added}",
has_active,
|s, p| {
let c = "spot.findBlemishes";
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let detector = super::face_detect::detector(s).map_err(|e| e.into_error(c))?;
// the uncropped photo, so positions are the spots' own coordinates
let job = s.render_job(id, 1600, 1600, false, false).ok_or_else(|| bad(c, "no photo"))?;
let img = job.run().rendered.map_err(|e| bad(c, e))?.image;
let rgb: Vec<u8> = img.data.iter().flat_map(|px| [px[0], px[1], px[2]]).collect();
let faces =
detector.detect(&rgb, img.width, img.height, &lightcraft_faces::yunet::Options::default()).map_err(|e| bad(c, e.to_string()))?;
let boxes: Vec<lightcraft_pipeline::blemish::FaceBox> = faces
.iter()
.map(|f| lightcraft_pipeline::blemish::FaceBox {
x0: f64::from(f.x0),
y0: f64::from(f.y0),
x1: f64::from(f.x1),
y1: f64::from(f.y1),
landmarks: f.landmarks.map(|(x, y)| (f64::from(x), f64::from(y))),
})
.collect();
let max_per_face = (f64_or(p, "maxPerFace", 6.0) as usize).clamp(1, 20);
let mut found = lightcraft_pipeline::blemish::detect(&img, &boxes, f64_or(p, "sensitivity", 50.0) as f32);
let mut per_face = std::collections::BTreeMap::new();
found.retain(|b| {
let n = per_face.entry(b.face).or_insert(0usize);
*n += 1;
*n <= max_per_face
});
let out: Vec<Value> = found.iter().map(|b| json!({"x": b.x, "y": b.y, "size": b.radius, "face": b.face})).collect();
if !p.get("add").and_then(Value::as_bool).unwrap_or(true) || found.is_empty() {
return Ok(json!({"faces": boxes.len(), "spots": out, "added": 0}));
}
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let base = Spot::default();
for b in &found {
let mut spot = Spot {
mode: SpotMode::Heal,
points: vec![Point::new(b.x, b.y)],
size: b.radius.clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: base.feather,
opacity: 100.0,
source_offset: None,
};
spot.source_offset = pick_source(s, id, &dd, &spot, None);
dd.spots.push(spot);
}
s.set_develop(id, dd, "Find Blemishes")?;
Ok(json!({"faces": boxes.len(), "spots": out, "added": found.len()}))
}
),
cmd!(
"spot.add",
"Add Remove Spot",
[],
None,
"{mode?: remove|heal|clone|fill (content-aware), points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
has_active,
|s, p| {
let mode: SpotMode =
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("remove"))).map_err(|e| bad("spot.add", e.to_string()))?;
let pts: Vec<Point> = p
.get("points")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect())
.unwrap_or_default();
if pts.is_empty() {
return Err(bad("spot.add", "missing points"));
}
let d = Spot::default();
let mut spot = Spot {
mode,
points: pts,
size: f64_or(p, "size", d.size).clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: f64_or(p, "feather", d.feather).clamp(0.0, 100.0),
opacity: f64_or(p, "opacity", d.opacity).clamp(0.0, 100.0),
source_offset: point(p, "source"),
};
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if spot.source_offset.is_none() && spot.mode != SpotMode::Fill {
// resolve the automatic source now: it gets a pin and stays put at every size
spot.source_offset = pick_source(s, id, &dd, &spot, None);
}
dd.spots.push(spot);
let n = dd.spots.len();
s.set_develop(id, dd, "Remove")?;
s.active_spot = Some(n - 1);
Ok(json!({"index": n - 1}))
}
),
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
let n = spots_len(s);
s.active_spot = match optional_index(p, "index", "spot.select")? {
Some(i) if i < n => Some(i),
Some(_) => return Err(bad("spot.select", "no such spot")),
None => None,
};
Ok(json!({"activeSpot": s.active_spot}))
}),
cmd!(
"spot.update",
"Edit Spot",
[],
None,
"{index? (the selected spot), move?: [dx,dy] (the target, normalized), source?: [dx,dy] (offset from the target), moveSource?: [dx,dy], size?, feather?, opacity?, mode?}",
has_active,
|s, p| {
let c = "spot.update";
let i = spot_index(s, p, c)?;
let mode: Option<SpotMode> = match p.get("mode") {
Some(m) => Some(serde_json::from_value(m.clone()).map_err(|e| bad(c, e.to_string()))?),
None => None,
};
spots_edit(s, c, "Edit Spot", |spots| {
let sp = &mut spots[i];
if let Some(d) = point(p, "move") {
sp.points.iter_mut().for_each(|q| *q = Point::new(q.x + d.x, q.y + d.y));
// the source moves along (its offset is relative to the target)
}
if let Some(o) = point(p, "source") {
sp.source_offset = Some(o);
}
if let Some(d) = point(p, "moveSource") {
let o = sp.source_offset.unwrap_or(Point::new(0.0, 0.0));
sp.source_offset = Some(Point::new(o.x + d.x, o.y + d.y));
}
if let Some(v) = p.get("size").and_then(Value::as_f64) {
sp.size = v.clamp(SPOT_SIZE.0, SPOT_SIZE.1);
}
if let Some(v) = p.get("feather").and_then(Value::as_f64) {
sp.feather = v.clamp(0.0, 100.0);
}
if let Some(v) = p.get("opacity").and_then(Value::as_f64) {
sp.opacity = v.clamp(0.0, 100.0);
}
if let Some(m) = mode {
sp.mode = m;
}
Ok(())
})?;
Ok(json!({"index": i}))
}
),
cmd!(
"spot.refreshSource",
"Refresh Source",
[],
None,
"{index? (the selected spot)} — picks the next-best source away from the current one",
has_active,
|s, p| {
let c = "spot.refreshSource";
let i = spot_index(s, p, c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let dd = (*s.develop_of(id).unwrap_or_default()).clone();
let spot = dd.spots[i].clone();
let next = pick_source(s, id, &dd, &spot, spot.source_offset).ok_or_else(|| bad(c, "no other source fits in the photo"))?;
spots_edit(s, c, "Refresh Source", |spots| {
spots[i].source_offset = Some(next);
Ok(())
})?;
Ok(json!({"index": i, "source": [next.x, next.y]}))
}
),
cmd!("spot.delete", "Delete Spot", [], None, "{index? (the selected spot)}", has_active, |s, p| {
let c = "spot.delete";
let i = spot_index(s, p, c)?;
spots_edit(s, c, "Delete Spot", |spots| {
spots.remove(i);
Ok(())
})?;
s.active_spot = match s.active_spot {
Some(a) if a == i => None,
Some(a) if a > i => Some(a - 1),
a => a,
};
ok()
}),
// ---- Red eye / pet eye
cmd!(
"redeye.add",
"Add Red Eye Correction",
[],
None,
"{center: [x,y] normalized, rx, ry: radii as fractions of the long edge, pet?: bool, pupilSize?: 0..100, darken?: 0..100} — the pupil inside is found automatically; returns {index}",
has_active,
|s, p| {
let c = "redeye.add";
let center = point(p, "center").ok_or_else(|| bad(c, "missing center"))?;
let d = RedEye::default();
let eye = RedEye {
center,
rx: f64_or(p, "rx", d.rx).clamp(1e-4, 0.5),
ry: f64_or(p, "ry", d.ry).clamp(1e-4, 0.5),
pupil_size: f64_or(p, "pupilSize", d.pupil_size).clamp(0.0, 100.0),
darken: f64_or(p, "darken", d.darken).clamp(0.0, 100.0),
pet: bool_or(p, "pet", false),
catchlight: None,
};
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
dd.red_eye.push(eye);
let n = dd.red_eye.len();
s.set_develop(id, dd, if eye.pet { "Pet Eye" } else { "Red Eye" })?;
Ok(json!({"index": n - 1}))
}
),
cmd!(
"redeye.catchlight",
"Pet Eye Catchlight",
[],
None,
"{index, on?: bool (default true), offset?: [dx, dy] from the pupil centre in pupil radii (default [-0.35, -0.35])}",
has_active,
|s, p| {
let c = "redeye.catchlight";
let i = super::index_req(p, "index", c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?;
if !eye.pet {
return Err(bad(c, "catchlights are for pet eyes"));
}
let off = point(p, "offset").or(eye.catchlight).unwrap_or(Point::new(-0.35, -0.35));
eye.catchlight = bool_or(p, "on", true).then(|| Point::new(off.x.clamp(-1.0, 1.0), off.y.clamp(-1.0, 1.0)));
s.set_develop(id, dd, "Catchlight")?;
ok()
}
),
cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| {
let c = "redeye.delete";
let i = super::index_req(p, "index", c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if i >= dd.red_eye.len() {
return Err(bad(c, "no such eye"));
}
dd.red_eye.remove(i);
s.set_develop(id, dd, "Delete Red Eye")?;
ok()
}),
]
}
/// Spot radius limits (fraction of the long edge).
pub const SPOT_SIZE: (f64, f64) = (0.001, 0.25);
fn spots_len(s: &Session) -> usize {
s.active().and_then(|id| s.develop_of(id)).map(|d| d.spots.len()).unwrap_or(0)
}
/// `index` from the params, else the selected spot.
fn spot_index(s: &Session, p: &Value, c: &str) -> Result<usize> {
let i = optional_index(p, "index", c)?.or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
if i >= spots_len(s) {
return Err(bad(c, "no such spot"));
}
Ok(i)
}
fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spot>) -> Result<()>) -> Result<()> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
f(&mut d.spots)?;
s.set_develop(id, d, label)
}
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
let info = s.source_info(id);
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
}
pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
c(&mut a.exposure, -4.0, 4.0);
for v in [
&mut a.temp,
&mut a.tint,
&mut a.contrast,
&mut a.highlights,
&mut a.shadows,
&mut a.whites,
&mut a.blacks,
&mut a.texture,
&mut a.clarity,
&mut a.dehaze,
&mut a.hue,
&mut a.saturation,
&mut a.sharpness,
&mut a.noise,
&mut a.moire,
&mut a.defringe,
] {
c(v, -100.0, 100.0);
}
c(&mut a.color_hue, 0.0, 360.0);
c(&mut a.color_sat, 0.0, 100.0);
c(&mut a.amount, 0.0, 200.0);
a
}
/// `mask.brushStroke`'s `pressure`: one number 0..1 per point (clamped), at most one per point; absent or `null`
/// is no pressure data.
fn pressure_list(v: Option<&Value>, points: usize) -> Result<Vec<f32>> {
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
let list = v.as_array().ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))?;
if list.len() > points {
return Err(bad("mask.brushStroke", "pressure has more values than points"));
}
list.iter()
.map(|q| q.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0) as f32))
.collect::<Option<Vec<f32>>>()
.ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))
}
#[cfg(test)]
mod tests_color_range {
use super::*;
/// Automation (`mask.add`/`mask.update`) used to take any number of colour-range samples; render
/// cost is pixels x samples, so a few hundred hung a large render. The list is now capped.
#[test]
fn color_range_samples_are_capped() {
let samples: Vec<Value> = (0..1000).map(|i| json!([i as f64 / 1000.0, 0.1, 0.2])).collect();
let shape = shape_from("colorRange", &json!({ "samples": samples }), "mask.add").unwrap();
let MaskShape::ColorRange { samples, .. } = shape else { panic!("expected ColorRange") };
assert_eq!(samples.len(), MAX_COLOR_RANGE_SAMPLES);
// A reasonable request is unchanged.
let few = shape_from("colorRange", &json!({ "samples": [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] }), "mask.add").unwrap();
let MaskShape::ColorRange { samples, .. } = few else { panic!() };
assert_eq!(samples.len(), 2);
}
}