LR-MASK-COLORRANGE: a colour-range mask keeps at most 64 samples however it is loaded (#2556)

Render cost is pixels x samples (CPU and GPU), and only mask.add / mask.update capped the list, so a
sidecar's lc:settings, a preset, develop.merge, render --settings or a catalog / history load could
carry thousands and stall a render or lose the GPU device. MAX_COLOR_RANGE_SAMPLES moves to
lightcraft-develop and ColorRange::samples deserializes through a capped visitor (the first 64 in
order, the rest skipped as IgnoredAny); the CPU and GPU renders also take at most 64 for settings
built in code.
This commit is contained in:
Bruno-Alumn
2026-10-30 12:03:03 +01:00
parent 445dd3106e
commit 06e2ec43ba
8 changed files with 115 additions and 11 deletions
+48
View File
@@ -271,6 +271,54 @@ mod tests {
assert_eq!(s.grading.blending, 50.0);
}
/// Settings with one colour-range mask of `n` distinct samples (built in code, so not capped).
fn color_range_settings(n: usize) -> DevelopSettings {
let samples = (0..n).map(|i| [i as f64 / 1000.0, 0.1, -0.1]).collect();
let mut s = DevelopSettings::default();
s.masks.push(Mask {
id: 1,
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::ColorRange { samples, refine: 50.0 } }],
..Default::default()
});
s
}
fn color_range_samples(s: &DevelopSettings) -> &[[f64; 3]] {
match s.masks.first().and_then(|m| m.components.first()).map(|c| &c.shape) {
Some(MaskShape::ColorRange { samples, .. }) => samples,
other => panic!("expected a colour range, got {other:?}"),
}
}
/// Issue #2556: `mask.add`/`mask.update` capped a colour range's samples, but a sidecar `lc:settings`, a
/// preset, `develop.merge`, `render --settings` or a catalog / history load took any number (render cost is
/// pixels x samples). Every load now keeps the first [`MAX_COLOR_RANGE_SAMPLES`], in order.
#[test]
fn color_range_samples_are_capped_however_settings_load() {
let big = color_range_settings(1000);
let want = &color_range_samples(&big)[..MAX_COLOR_RANGE_SAMPLES];
let v = big.to_json();
let loaded = DevelopSettings::from_json(&v).unwrap();
assert_eq!(color_range_samples(&loaded).len(), MAX_COLOR_RANGE_SAMPLES);
assert_eq!(color_range_samples(&loaded), want);
let from_str: DevelopSettings = serde_json::from_str(&v.to_string()).unwrap();
assert_eq!(color_range_samples(&from_str), want);
// `develop.merge`, `render --settings` and presets merge a partial
let merged = DevelopSettings::default().merged(&json!({"masks": v["masks"]})).unwrap();
assert_eq!(color_range_samples(&merged), want);
let preset =
Preset { id: "p".into(), name: "P".into(), group: "G".into(), settings: json!({"masks": v["masks"]}), favorite: false, builtin: false };
assert_eq!(color_range_samples(&preset.apply(&DevelopSettings::default(), 1.0)), want);
// a capped list round-trips, and a short one is unchanged
assert_eq!(DevelopSettings::from_json(&loaded.to_json()).unwrap(), loaded);
let few = color_range_settings(2);
assert_eq!(DevelopSettings::from_json(&few.to_json()).unwrap(), few);
// a list that isn't samples is still refused
let mut bad = v.clone();
bad["masks"][0]["components"][0]["shape"]["samples"] = json!([[0.1, 0.2]]);
assert!(DevelopSettings::from_json(&bad).is_err());
}
#[test]
fn merge_partial() {
let s = DevelopSettings::default();
+31 -1
View File
@@ -921,6 +921,35 @@ impl Default for BrushStroke {
}
}
/// Most samples a colour-range mask keeps. It compares every pixel against every sample, so render and export cost
/// is pixels x samples; the interactive sampler stops at 5, and a sidecar, preset or `mask.add` with thousands could
/// stall a render or lose the GPU device (issue #2556).
pub const MAX_COLOR_RANGE_SAMPLES: usize = 64;
/// Deserialize a colour range's samples, keeping the first [`MAX_COLOR_RANGE_SAMPLES`] (the rest are skipped without
/// being stored), however the settings arrive: sidecar, catalog, history, preset or merge.
fn de_color_range_samples<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<[f64; 3]>, D::Error> {
struct Capped;
impl<'de> serde::de::Visitor<'de> for Capped {
type Value = Vec<[f64; 3]>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of OkLab colours")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<[f64; 3]>, A::Error> {
let mut out = Vec::new();
while out.len() < MAX_COLOR_RANGE_SAMPLES {
match seq.next_element::<[f64; 3]>()? {
Some(s) => out.push(s),
None => return Ok(out),
}
}
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
Ok(out)
}
}
d.deserialize_seq(Capped)
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum MaskShape {
@@ -941,8 +970,9 @@ pub enum MaskShape {
feather: f64,
invert: bool,
},
/// Colour range: sampled OkLab colours with a refine amount 0..100.
/// Colour range: sampled OkLab colours (at most [`MAX_COLOR_RANGE_SAMPLES`]) with a refine amount 0..100.
ColorRange {
#[serde(deserialize_with = "de_color_range_samples")]
samples: Vec<[f64; 3]>,
refine: f64,
},