mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user