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,
},
+1 -6
View File
@@ -1,6 +1,6 @@
//! Masking and Remove (spot) commands on the active photo.
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode};
use lightcraft_develop::{BrushStroke, LocalAdjustments, MAX_COLOR_RANGE_SAMPLES, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode};
use lightcraft_geom::Point;
use serde_json::{Value, json};
@@ -8,11 +8,6 @@ use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_p
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;
/// Caps a colour-range shape's sample list at [`MAX_COLOR_RANGE_SAMPLES`] (render cost is pixels x samples).
fn cap_color_range_samples(shape: &mut MaskShape) {
if let MaskShape::ColorRange { samples, .. } = shape {
+17
View File
@@ -980,6 +980,23 @@ mod tests {
assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()]));
}
/// Issue #2556: a sidecar's `lc:settings` colour-range mask keeps at most `MAX_COLOR_RANGE_SAMPLES` samples.
#[test]
fn sidecar_color_range_samples_are_capped() {
use lightcraft_develop::{MAX_COLOR_RANGE_SAMPLES, Mask, MaskComponent, MaskOp, MaskShape};
let samples: Vec<[f64; 3]> = (0..1000).map(|i| [i as f64 / 1000.0, 0.1, -0.1]).collect();
let mut d = DevelopSettings::default();
let shape = MaskShape::ColorRange { samples: samples.clone(), refine: 50.0 };
d.masks.push(Mask { id: 1, components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], ..Default::default() });
let xmp = lightcraft_meta::write_xmp(&lightcraft_meta::Metadata::default(), Some(&d.to_json().to_string()));
let sc = super::parse_sidecar(&xmp, crate::crs::Target::Rendered).unwrap();
let Some(DevelopPatch::Full(s)) = sc.develop else { panic!("expected full settings") };
let Some(MaskShape::ColorRange { samples: got, .. }) = s.masks.first().and_then(|m| m.components.first()).map(|c| &c.shape) else {
panic!("expected a colour range")
};
assert_eq!(got.as_slice(), &samples[..MAX_COLOR_RANGE_SAMPLES]);
}
use super::*;
use lightcraft_catalog::Meta;
+3 -1
View File
@@ -1017,8 +1017,10 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
}
MaskShape::ColorRange { samples, refine } => {
let tol = lightcraft_pipeline::masks::color_range_tolerance(*refine);
// at most the CPU's samples (`MAX_COLOR_RANGE_SAMPLES`): cost is pixels x samples
let samples = samples.iter().take(lightcraft_develop::MAX_COLOR_RANGE_SAMPLES);
p.extend([tol.to_bits(), ev.exp2().to_bits(), samples.len() as u32]);
aux.extend(samples.iter().flat_map(|s| s.map(|v| v as f32)));
aux.extend(samples.flat_map(|s| s.map(|v| v as f32)));
Some(3)
}
MaskShape::Brush { strokes } => {
+3 -1
View File
@@ -251,7 +251,9 @@ pub fn shape_alpha(shape: &MaskShape, frame: &Frame, w: usize, h: usize, img: &R
}
MaskShape::ColorRange { samples, refine } => {
let tol = color_range_tolerance(*refine);
let samples: Vec<[f32; 3]> = samples.iter().map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
// (a settings value built in code can skip the deserializer's cap: cost is pixels x samples)
let samples: Vec<[f32; 3]> =
samples.iter().take(lightcraft_develop::MAX_COLOR_RANGE_SAMPLES).map(|s| [s[0] as f32, s[1] as f32, s[2] as f32]).collect();
for (v, c) in out.data.iter_mut().zip(&img.data) {
let lab = lightcraft_color::perceptual::oklab_from_2020(tonemap_for_select(c.map(|v| v * gain)));
let d = samples
+3 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** patch (LR-MASK-COLORRANGE: a colour-range mask keeps at most 64 samples however it is loaded, #2556; earlier: LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -299,7 +299,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt |
| LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | |
| LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested |
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider |
| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider; at most 64 samples (`MAX_COLOR_RANGE_SAMPLES`, render cost is pixels × samples) however the mask arrives: `mask.add` / `mask.update`, a sidecar's `lc:settings`, a preset, `develop.merge`, `render --settings` or the catalog and history keep the first 64 (issue #2556) |
| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (the pressed endpoint stays selected through fast/crossing drags; one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) |
| LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data |
| LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent`, `cmd:tool.brush` | Add/Subtract > Brush creates a component with the chosen operation; Erase removes paint within that brush component |
@@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) |
| 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone |
| 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |
| 2026-10-30 | patch | LR-MASK-COLORRANGE: a colour-range mask keeps its first 64 samples however it is loaded (sidecar `lc:settings`, presets, `develop.merge`, `render --settings`, catalog and history), not only through `mask.add` / `mask.update`, so a document with thousands can't stall a render or lose the GPU device (issue #2556); status unchanged (✅) |
+9
View File
@@ -1,5 +1,14 @@
# What's new in LightCraft
## 2026-10-30
### A colour-range mask keeps at most 64 samples, however it arrives
- A colour-range mask compares every pixel with every colour it has sampled, so a mask with thousands of samples
could stall a render or export, or lose the GPU for the rest of the session. `mask.add` and `mask.update` already
kept the first 64; a sidecar's `lc:settings`, a preset, `develop.merge`, `render --settings` and the catalog and
edit history now keep the first 64 too, in order, and the extra ones are skipped as they are read. Masks made in
the app (up to 5 samples) are unchanged.
## 2026-10-24
### Relative-date filters and smart albums follow the clock