diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index a40f094c2..0064a4f81 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -134,6 +134,7 @@ pub struct DevelopSettings { pub orientation: Orientation, pub masks: Vec, pub spots: Vec, + /// Red eye corrections; the first [`MAX_RED_EYES`] render. pub red_eye: Vec, pub lens_blur: LensBlur, pub enhance: Enhance, @@ -1069,6 +1070,10 @@ impl Default for Spot { } } +/// Most red eye / pet eye corrections a photo renders. Every output pixel goes through every eye, so render cost is +/// pixels x eyes; a sidecar, preset or agent with thousands could stall a render for hours (issue #2556). +pub const MAX_RED_EYES: usize = 64; + /// A red eye / pet eye correction: the user's ellipse (centre normalized, radii as fractions of /// the long edge); the pupil inside it is found automatically. #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 2fd44d566..6ec6d39f5 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -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_RED_EYES, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode}; use lightcraft_geom::Point; use serde_json::{Value, json}; @@ -1025,6 +1025,9 @@ pub fn specs() -> Vec { catchlight: None, }; let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + if s.develop_of(id).is_some_and(|d| d.red_eye.len() >= MAX_RED_EYES) { + return Err(bad(c, format!("at most {MAX_RED_EYES} red eye corrections"))); + } let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); dd.red_eye.push(eye); let n = dd.red_eye.len(); diff --git a/crates/engine/src/tests/color.rs b/crates/engine/src/tests/color.rs index e526d4c47..af59f02be 100644 --- a/crates/engine/src/tests/color.rs +++ b/crates/engine/src/tests/color.rs @@ -75,6 +75,12 @@ fn red_eye_commands_and_controls() { assert_eq!(active_dev(&s).red_eye.len(), 1); assert!(s.execute("redeye.delete", &json!({"index": 5})).is_err()); assert!(s.execute("redeye.add", &json!({"rx": 0.03})).is_err()); + // at most MAX_RED_EYES: every pixel goes through every eye (issue #2556) + while active_dev(&s).red_eye.len() < lightcraft_develop::MAX_RED_EYES { + s.execute("redeye.add", &json!({"center": [0.5, 0.5], "rx": 0.01, "ry": 0.01})).unwrap(); + } + assert!(s.execute("redeye.add", &json!({"center": [0.5, 0.5], "rx": 0.01, "ry": 0.01})).is_err()); + assert_eq!(active_dev(&s).red_eye.len(), lightcraft_develop::MAX_RED_EYES); let d = active_dev(&s); assert_eq!(lightcraft_develop::DevelopSettings::from_json(&d.to_json()).unwrap(), d); } diff --git a/crates/pipeline/src/redeye.rs b/crates/pipeline/src/redeye.rs index 3bad1d634..bb2944388 100644 --- a/crates/pipeline/src/redeye.rs +++ b/crates/pipeline/src/redeye.rs @@ -142,6 +142,7 @@ pub fn resolve(src: &Rgb32f, eyes: &[RedEye], orientation: Orientation, frame: & } let to_out = frame.norm_to_out(w, h); eyes.iter() + .take(lightcraft_develop::MAX_RED_EYES) .map(|e| { let p = detect(src, orientation, frame.ow, frame.oh, e); let r = (p.radius * (0.5 + e.pupil_size.clamp(0.0, 100.0) / 100.0)).min(e.rx.max(e.ry) * 1.1); @@ -246,6 +247,16 @@ mod tests { assert!(!detect(&plain, Orientation::Normal, 200.0, 100.0, &eye).found); } + #[test] + fn at_most_max_red_eyes_render() { + // a sidecar, preset or agent can hold any number; each one costs a pass over every pixel (issue #2556) + let src = eye_image(40, 20, 18.0, 10.0, 2.0, [0.5, 0.04, 0.04]); + let s = lightcraft_develop::DevelopSettings { red_eye: vec![RedEye::default(); 10_000], ..Default::default() }; + let frame = Frame::new(40, 20, &s, true); + let eyes = resolve(&src, &s.red_eye, Orientation::Normal, &frame, 40, 20, 40.0); + assert_eq!(eyes.len(), lightcraft_develop::MAX_RED_EYES); + } + #[test] fn renders_fix_the_red_pupil_at_any_size() { use crate::{RenderRequest, SourceInfo, render}; diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..d9d3cbba5 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -1,5 +1,11 @@ # What's new in LightCraft +## 2026-10-30 + +### At most 64 red eye corrections per photo +- Every pixel of a render goes through every red eye correction, so a sidecar, preset or agent with thousands of + them could stall a preview or export for hours. A photo now renders its first 64, and Red Eye refuses a 65th. + ## 2026-10-24 ### Relative-date filters and smart albums follow the clock