diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 75ec07e59f..fc93ea9f13 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -64,6 +64,7 @@ //! | 10 | masks stay on the photo under lens corrections, Upright and Transform | `Mask.on_photo` (develop settings) | //! | 11 | lens database profiles named in the Optics panel | `EmbeddedLens.source` | //! | 12 | history steps and versions share segmentation grids (#2154) | `Catalog.grids`, `SegMask.grid` | +//! | 13 | Lens Blur | `DevelopSettings.lens_blur`, `Mask.adjust.lens_blur` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -88,7 +89,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 12; +pub const VERSION: u32 = 13; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index 612ee4586b..248933a4a2 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -238,9 +238,9 @@ fn op_variants_are_versioned() { Op::SetHistoryCursor { .. } => 8, } } - /// v12: `Catalog.grids` and `SegMask.grid`, shared by a snapshot's masks (#2154) (v11: `EmbeddedLens.source`; v10: `Mask.on_photo`; v9: - /// `Photo.preview_checked` and `Op::SetContent.preview_checked`). - const NEWEST_FIELDS: u32 = 12; + /// v13: Lens Blur in develop settings and mask adjustments (v12: `Catalog.grids` and `SegMask.grid`; v11: `EmbeddedLens.source`; + /// v10: `Mask.on_photo`; v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`). + const NEWEST_FIELDS: u32 = 13; let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version"); } diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index 9d384f57c4..589bcc6f19 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -28,6 +28,7 @@ pub enum Section { Calibration, PointColor, RedEye, + LensBlur, } impl Section { @@ -49,6 +50,7 @@ impl Section { Section::Calibration => "Calibration", Section::PointColor => "Point Color", Section::RedEye => "Red Eye", + Section::LensBlur => "Lens Blur", } } } @@ -286,6 +288,9 @@ controls! { "geometry.scale" => geometry.scale, "Scale", Geometry, 50, 150, 100, 1, 0, Plain; "geometry.offsetX" => geometry.offset_x, "Offset X", Geometry, -100, 100, 0, 0.1, 1, Centered; "geometry.offsetY" => geometry.offset_y, "Offset Y", Geometry, -100, 100, 0, 0.1, 1, Centered; + "lensBlur.amount" => lens_blur.amount, "Blur Amount", LensBlur, 0, 100, 0, 1, 0, Plain; + "lensBlur.boost" => lens_blur.boost, "Boost", LensBlur, 0, 100, 0, 1, 0, Plain; + "lensBlur.transition" => lens_blur.transition, "Transition", LensBlur, 0, 100, 50, 1, 0, Plain; "calibration.shadowsTint" => calibration.shadows_tint, "Shadows Tint", Calibration, -100, 100, 0, 1, 0, Tint; "calibration.redHue" => calibration.red_hue, "Red Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#e0306a", to: "#e08a30" }; "calibration.redSat" => calibration.red_sat, "Red Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e03a3a" }; diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index cb5acab6f5..0e90df716a 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -200,6 +200,14 @@ impl DevelopSettings { } } + /// The middle of the crop, in normalized photo coordinates (the photo's middle when the crop + /// isn't a usable rectangle). + pub fn crop_center(&self) -> lightcraft_geom::Point { + let r = self.crop.geometry.rect; + let (x, y) = ((r.x0 + r.x1) / 2.0, (r.y0 + r.y1) / 2.0); + if x.is_finite() && y.is_finite() { lightcraft_geom::Point::new(x, y) } else { lightcraft_geom::Point::new(0.5, 0.5) } + } + /// Next free mask id: one past the highest, or, when that is `u32::MAX` (ids come from files, /// pastes and agents), the lowest one no mask uses. pub fn next_mask_id(&self) -> u32 { diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 7b0226d9e7..a4e6eab147 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -898,6 +898,25 @@ pub struct LocalAdjustments { pub noise: f64, pub moire: f64, pub defringe: f64, + /// Lens Blur inside the mask, 0..100 (`lightcraft_pipeline::lens_blur`): disc (bokeh) blur + /// whose radius follows the mask's strength. Omitted while 0, so masks without it keep their + /// JSON (and hash). + #[serde(skip_serializing_if = "is_zero")] + pub lens_blur: f64, + /// The mask's Lens Blur block switched off (its values kept). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub lens_blur_off: bool, + /// Blur what the mask leaves out instead of what it selects (the mask's other adjustments + /// still go where it selects). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub lens_blur_outside: bool, + /// Brighter bokeh highlights in the mask's blur, 0..100. + #[serde(skip_serializing_if = "is_zero")] + pub lens_blur_boost: f64, + /// How gradually the mask's blur grows away from what stays sharp, 0..100 (0: it follows the + /// mask's own edge). + #[serde(skip_serializing_if = "is_zero")] + pub lens_blur_transition: f64, /// Colour overlay (hue degrees, saturation 0..100); sat 0 = none. pub color_hue: f64, pub color_sat: f64, @@ -925,6 +944,11 @@ impl Default for LocalAdjustments { noise: 0.0, moire: 0.0, defringe: 0.0, + lens_blur: 0.0, + lens_blur_off: false, + lens_blur_outside: false, + lens_blur_boost: 0.0, + lens_blur_transition: 0.0, color_hue: 0.0, color_sat: 0.0, amount: 100.0, @@ -1266,13 +1290,157 @@ impl Default for RedEye { } } -#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +/// Lens Blur: a shallow depth of field added after the fact (`lightcraft_pipeline::lens_blur`). +/// What stays sharp is the photo's subject, or the selection of the mask [`LensBlur::focus_mask`] +/// names; everything else gets disc-shaped (bokeh) blur up to [`LensBlur::amount`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct LensBlur { pub enabled: bool, + /// Blur of what is out of focus, 0..100 (100: a disc of 3.5 % of the long edge). pub amount: f64, + /// Reserved for a depth map (not rendered yet). pub focal_distance: f64, + /// Reserved for a depth map (not rendered yet). pub focal_range: f64, + /// How much brighter the out-of-focus highlights turn into bokeh discs, 0..100. Omitted while + /// 0, so settings without it keep their JSON (and hash). + #[serde(skip_serializing_if = "is_zero")] + pub boost: f64, + /// The id of the mask whose selection stays in focus (any mask, shown or hidden); `None`, or a + /// mask that no longer exists: the photo's subject. + #[serde(skip_serializing_if = "Option::is_none")] + pub focus_mask: Option, + /// Brush Refinement, Focus brush: strokes painted in focus (their strength: how far; erase + /// strokes take it back). Omitted while empty. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub focus_strokes: Vec, + /// Brush Refinement, Blur brush: strokes painted out of focus, over the focus and the Focus + /// brush. Omitted while empty. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub blur_strokes: Vec, + /// How gradually the blur grows away from what is in focus, 0..100: 0 a sharp cut at the + /// focus's edge, higher a longer ramp (a stand-in for depth until there is a depth map). + /// Omitted at its default, so settings without it keep their JSON (and hash). + #[serde(skip_serializing_if = "LensBlur::is_default_transition")] + pub transition: f64, + /// What stays in focus without a focus mask: the subject estimate, or a fixed shape in the + /// middle of the framing. Omitted while automatic. + #[serde(skip_serializing_if = "FocusShape::is_auto")] + pub shape: FocusShape, + /// Where a fixed shape sits and how large it is, once moved or resized on the photo; `None`: + /// the shape's preset, in the middle of the crop. + #[serde(skip_serializing_if = "Option::is_none")] + pub area: Option, +} + +/// A fixed focus shape on the photo: an upright ellipse. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct FocusArea { + /// Normalized (uncropped, oriented) photo coordinates, as mask shapes. + pub center: Point, + /// Half-width and half-height, in units of the photo's long edge. + pub rx: f64, + pub ry: f64, +} + +impl FocusArea { + /// Smallest and largest radius (long-edge units). + pub const MIN_RADIUS: f64 = 0.02; + pub const MAX_RADIUS: f64 = 1.0; + + /// Usable values: finite, the radii within range, the centre no farther than a photo's width + /// outside it. A hostile value falls back to the middle and the default size. + pub fn sanitized(self) -> FocusArea { + let r = |v: f64, d: f64| if v.is_finite() { v.clamp(Self::MIN_RADIUS, Self::MAX_RADIUS) } else { d }; + let c = |v: f64| if v.is_finite() { v.clamp(-1.0, 2.0) } else { 0.5 }; + FocusArea { center: Point::new(c(self.center.x), c(self.center.y)), rx: r(self.rx, 0.22), ry: r(self.ry, 0.22) } + } +} + +impl LensBlur { + /// The fixed focus shape in effect, if any: the moved / resized [`LensBlur::area`], else the + /// shape's preset centred on `crop_center`. `None` for [`FocusShape::Auto`]. + pub fn focus_area(&self, crop_center: Point) -> Option { + let (rx, ry) = self.shape.radii()?; + Some(self.area.unwrap_or(FocusArea { center: crop_center, rx, ry }).sanitized()) + } +} + +/// The focus area [`LensBlur`] uses when no mask says what is in focus. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum FocusShape { + /// A circle. + Round, + /// An oval lying down (landscape). + Wide, + /// An oval standing up (portrait). + Tall, + /// The photo's subject, estimated (person mattes, else a saliency heuristic). Also what a + /// shape this build doesn't know reads as (so it is last: serde's `other`). + #[default] + #[serde(other)] + Auto, +} + +impl FocusShape { + pub const ALL: [FocusShape; 4] = [FocusShape::Auto, FocusShape::Round, FocusShape::Wide, FocusShape::Tall]; + + /// Whether this is the default (Auto: no fixed shape), for skipping it in the JSON. + fn is_auto(&self) -> bool { + *self == FocusShape::Auto + } + + /// The fixed shape's half-width and half-height, in units of the photo's long edge; `None` + /// for [`FocusShape::Auto`]. + pub fn radii(self) -> Option<(f64, f64)> { + match self { + FocusShape::Auto => None, + FocusShape::Round => Some((0.22, 0.22)), + FocusShape::Wide => Some((0.32, 0.17)), + FocusShape::Tall => Some((0.17, 0.32)), + } + } + + /// The name shown on the shape's button (translated by the UI). + pub fn label(self) -> &'static str { + match self { + FocusShape::Auto => "Auto", + FocusShape::Round => "Round", + FocusShape::Wide => "Horizontal", + FocusShape::Tall => "Vertical", + } + } +} + +impl LensBlur { + /// [`LensBlur::transition`]'s default. + pub const DEFAULT_TRANSITION: f64 = 50.0; + + /// Whether `v` is [`Self::DEFAULT_TRANSITION`], for skipping it in the JSON. + fn is_default_transition(v: &f64) -> bool { + *v == Self::DEFAULT_TRANSITION + } +} + +impl Default for LensBlur { + /// Off, with the default Transition and everything else empty. + fn default() -> Self { + Self { + enabled: false, + amount: 0.0, + focal_distance: 0.0, + focal_range: 0.0, + boost: 0.0, + focus_mask: None, + focus_strokes: Vec::new(), + blur_strokes: Vec::new(), + transition: Self::DEFAULT_TRANSITION, + shape: FocusShape::Auto, + area: None, + } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 5403ad6eb2..5223710ed6 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -8,6 +8,9 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param}; use crate::{Result, Session, media::SourceLevel}; +/// Strokes one Lens Blur refinement brush keeps (a document can't grow without bound). +const MAX_LENS_BLUR_STROKES: usize = 2000; + fn active(s: &Session, c: &str) -> Result { s.active().ok_or_else(|| bad(c, "no active photo")) } @@ -482,6 +485,150 @@ pub fn specs() -> Vec { Ok(()) }) }), + cmd!( + "lensBlur.brushStroke", + "Lens Blur Brush Stroke", + [], + None, + "{mode: \"focus\"|\"blur\", points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?: 0..100, flow?: 1..100, amount?: 1..100 (how far the stroke refines), erase?: bool, autoMask?: bool, pressure?: [p,…] 0..1 per point} — Lens Blur Brush Refinement: Focus strokes stay sharp, Blur strokes are blurred", + has_active, + |s, p| { + const C: &str = "lensBlur.brushStroke"; + let focus = match str_param(p, "mode") { + Some("focus") => true, + Some("blur") => false, + _ => return Err(bad(C, "`mode` must be \"focus\" or \"blur\"")), + }; + let points: Vec = p + .get("points") + .and_then(Value::as_array) + .ok_or_else(|| bad(C, "missing points"))? + .iter() + .filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))) + .filter(|q| q.x.is_finite() && q.y.is_finite()) + .collect(); + if points.is_empty() { + return Err(bad(C, "a stroke needs at least one point")); + } + let d = lightcraft_develop::BrushStroke::default(); + let num = |key: &str, def: f64, lo: f64, hi: f64| { + let v = f64_or(p, key, def); + if v.is_finite() { v.clamp(lo, hi) } else { def } + }; + let stroke = lightcraft_develop::BrushStroke { + pressure: super::masks::pressure_list(p.get("pressure"), points.len())?, + points, + size: num("size", d.size, 0.001, 0.5), + feather: num("feather", d.feather, 0.0, 100.0), + flow: num("flow", d.flow, 1.0, 100.0), + density: num("amount", d.density, 1.0, 100.0), + erase: bool_or(p, "erase", false), + auto_mask: bool_or(p, "autoMask", false), + }; + edit(s, C, if focus { "Lens Blur Focus Brush" } else { "Lens Blur Blur Brush" }, |d| { + let strokes = if focus { &mut d.lens_blur.focus_strokes } else { &mut d.lens_blur.blur_strokes }; + if strokes.len() >= MAX_LENS_BLUR_STROKES { + return Err(bad(C, format!("at most {MAX_LENS_BLUR_STROKES} strokes per brush: Reset the brush first"))); + } + strokes.push(stroke); + Ok(()) + }) + } + ), + cmd!( + "lensBlur.findSubject", + "Find Subject", + [], + None, + "{} — selects the photo's subject with an Object mask (SAM 3) clicked where the camera focused, when the file records it, else at the likeliest subject; Lens Blur is switched on and keeps that mask in focus → {mask, point: [x,y], from: \"camera\"|\"photo\", pending?}", + has_active, + |s, _| { + const C: &str = "lensBlur.findSubject"; + let id = active(s, C)?; + let d = s.develop_of(id).unwrap_or_default(); + let camera = s.catalog.photo(id).and_then(|p| p.meta.focus.clone()); + let (point, from) = match camera { + // stored on the upright photo: the user's Rotate / Flip on top, as masks are + Some(f) => { + let r = d.orientation.map_norm_rect(f.rect); + (Point::new((r.x0 + r.x1) / 2.0, (r.y0 + r.y1) / 2.0), "camera") + } + None => { + let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad(C, e))?; + let info = s.source_info(id); + let p = lightcraft_pipeline::lens_blur::subject_point(&src, &d, info.mattes.as_deref()) + .ok_or_else(|| bad(C, "the photo has no picture to look for a subject in"))?; + (p, "photo") + } + }; + if !(point.x.is_finite() && point.y.is_finite()) { + return Err(bad(C, "the camera's focus point is not usable")); + } + let point = Point::new(point.x.clamp(0.0, 1.0), point.y.clamp(0.0, 1.0)); + // an Object waiting for the subject (the photo is analyzed meanwhile); the + // segmenter fills it with the people / animals the focus falls on, or the object + // at the point + let mut out = s.execute("mask.add", &json!({"kind": "object", "name": "Lens Blur Subject"}))?; + let mask = out + .get("activeMask") + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| bad(C, "the subject mask was not created"))?; + let focus = (from == "camera").then_some(point); + if let Err(e) = s.segment_subject_later(id, mask, 0, point, focus) { + // nothing half-made: the empty mask goes again + let _ = s.execute("edit.undo", &json!({})); + return Err(crate::EngineError::Other(e)); + } + out["pending"] = json!(s.segmenter.background); + edit(s, C, "Lens Blur Subject", |d| { + // a mask that only says what is in focus: no adjustments of its own + if let Some(m) = d.masks.iter_mut().find(|m| m.id == mask) { + m.adjust = lightcraft_develop::LocalAdjustments::default(); + } + let b = &mut d.lens_blur; + b.enabled = true; + if b.amount <= 0.0 || !b.amount.is_finite() { + b.amount = 50.0; + } + b.focus_mask = Some(mask); + d.set_section_enabled("lensBlur", true); + Ok(()) + })?; + if let Some(o) = out.as_object_mut() { + o.insert("mask".into(), json!(mask)); + o.insert("point".into(), json!([point.x, point.y])); + o.insert("from".into(), json!(from)); + } + Ok(out) + } + ), + cmd!( + "lensBlur.brushReset", + "Reset Lens Blur Brush", + [], + None, + "{mode?: \"focus\"|\"blur\"} — removes that brush's strokes (both brushes without `mode`)", + has_active, + |s, p| { + const C: &str = "lensBlur.brushReset"; + let (focus, blur) = match str_param(p, "mode") { + None => (true, true), + Some("focus") => (true, false), + Some("blur") => (false, true), + Some(_) => return Err(bad(C, "`mode` must be \"focus\" or \"blur\"")), + }; + edit(s, C, "Reset Lens Blur Brush", |d| { + if focus { + d.lens_blur.focus_strokes.clear(); + } + if blur { + d.lens_blur.blur_strokes.clear(); + } + Ok(()) + }) + } + ), cmd!("develop.sectionEnabled", "Toggle Section", [], None, "{section: string, enabled: bool}", has_active, |s, p| { let sec = str_param(p, "section").ok_or_else(|| bad("develop.sectionEnabled", "missing section"))?.to_string(); let on = bool_or(p, "enabled", true); diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs index d791b42606..529566f3bb 100644 --- a/crates/engine/src/cmd/mask_effects.rs +++ b/crates/engine/src/cmd/mask_effects.rs @@ -33,6 +33,7 @@ pub const FIELDS: &[&str] = &[ "noise", "moire", "defringe", + "lens_blur", "color_hue", "color_sat", ]; @@ -118,10 +119,11 @@ pub fn get(a: &LocalAdjustments, k: &str) -> f64 { serde_json::to_value(a).ok().and_then(|v| v.get(k).and_then(Value::as_f64)).unwrap_or(0.0) } -/// `a` with field `k` set to `v` (unchanged for an unknown name). +/// `a` with field `k` set to `v` (unchanged for an unknown name). A [`FIELDS`] slider counts as +/// known also while its JSON leaves it out at 0 (`lens_blur`). pub fn set(a: &LocalAdjustments, k: &str, v: f64) -> LocalAdjustments { let Ok(Value::Object(mut o)) = serde_json::to_value(a) else { return *a }; - if !o.contains_key(k) || !v.is_finite() { + if !(o.contains_key(k) || FIELDS.contains(&k)) || !v.is_finite() { return *a; } o.insert(k.into(), json!(v)); @@ -707,6 +709,22 @@ mod tests { assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried"); } + /// A partial effect sets Lens Blur on a mask without any yet (its JSON leaves `lens_blur` out + /// at 0); a name no slider has still changes nothing. + #[test] + fn partial_effects_set_lens_blur_from_zero() { + let e = MaskEffect { + name: "Bokeh".into(), + adjust: LocalAdjustments { lens_blur: 60.0, ..Default::default() }, + fields: vec!["lens_blur".into()], + ..Default::default() + }; + let out = e.apply(&LocalAdjustments::default()); + assert_eq!(out.lens_blur, 60.0); + assert!(e.matches(&out)); + assert_eq!(set(&LocalAdjustments::default(), "bogus", 5.0), LocalAdjustments::default()); + } + #[test] fn hue_without_saturation_still_matches() { let e = MaskEffect { diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 06168989a6..e4017f4003 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -162,7 +162,11 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec Vec { "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}}", + "{id?, values: {exposure, contrast, highlights, shadows, whites, blacks, temp, tint, texture, clarity, dehaze, hue, saturation, sharpness, noise, moire, defringe, lens_blur, lens_blur_off, lens_blur_outside, lens_blur_boost, lens_blur_transition, color_hue, color_sat, amount}}", has_active, |s, p| { let vals = p.get("values").cloned().ok_or_else(|| bad("mask.adjust", "missing values"))?; @@ -1151,6 +1155,9 @@ pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { ] { c(v, -100.0, 100.0); } + c(&mut a.lens_blur, 0.0, 100.0); + c(&mut a.lens_blur_boost, 0.0, 100.0); + c(&mut a.lens_blur_transition, 0.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); @@ -1159,7 +1166,7 @@ pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { /// `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> { +pub(super) fn pressure_list(v: Option<&Value>, points: usize) -> Result> { 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 { diff --git a/crates/engine/src/segment.rs b/crates/engine/src/segment.rs index 0f99636122..9c479cb176 100644 --- a/crates/engine/src/segment.rs +++ b/crates/engine/src/segment.rs @@ -64,6 +64,9 @@ pub(crate) enum Tag { NewPrompt { photo: PhotoId, mask: Option, op: String, name: Option, text: String }, /// A zoomed-in pass for a component, computed from `shape` (without its detail). Detail { photo: PhotoId, mask: u32, comp: usize, shape: MaskShape }, + /// Find Subject for component `comp` of mask `mask` (an Object waiting for it): the people / + /// animals found become a "person" Describe selection; none, the object at `point`. + Subject { photo: PhotoId, mask: u32, comp: usize, point: Point }, } /// The model input: 8-bit RGB. @@ -573,6 +576,35 @@ impl Session { } } + /// Find Subject for component `comp` of mask `mask` on photo `id` (an Object without clicks): + /// the photo's main people / animals ([`worker::Kind::Subject`]), those the camera's `focus` + /// falls on when the file records it; when the subject is neither, the object at `point`. In + /// background mode [`Session::segment_poll`] applies the result; otherwise it is applied + /// before this returns. + pub(crate) fn segment_subject_later(&mut self, id: PhotoId, mask: u32, comp: usize, point: Point, focus: Option) -> Result<(), String> { + self.segmenter.model_dir()?; + #[cfg(feature = "sam")] + { + let kind = worker::Kind::Subject { focus: focus.map(|p| (p.x as f32, p.y as f32)) }; + let tag = Tag::Subject { photo: id, mask, comp, point }; + if self.segmenter.background { + return self.segment_queue(id, kind, tag, None); + } + let seg = self.segment_wait(id, kind, None)?; + let mut polled = Polled::default(); + self.apply_outcome(worker::Outcome { tag, result: Ok(seg), superseded: false, notice: None }, &mut polled); + match polled.messages.into_iter().next() { + Some(e) => Err(e), + None => Ok(()), + } + } + #[cfg(not(feature = "sam"))] + { + let _ = (id, mask, comp, point, focus); + Err("AI masks are not available in this build".into()) + } + } + /// Start a zoomed-in pass for component `comp` of mask `mask` on photo `id` (an Object or /// Describe selection): the photo around the selection is rendered at a higher resolution /// and segmented again. In background mode [`Session::segment_poll`] applies the result; @@ -709,6 +741,51 @@ impl Session { Err(e) => polled.messages.push(e.to_string()), } } + Tag::Subject { photo, mask, comp, point } => { + let found = match o.result { + Ok(found) => found, + Err(e) => return polled.messages.push(e), + }; + let Some(d) = self.develop_of(photo) else { return }; + let mut d = (*d).clone(); + let Some(c) = d.masks.iter_mut().find(|m| m.id == mask).and_then(|m| m.components.get_mut(comp)) else { return }; + // still the Object waiting for it (not clicked or replaced meanwhile) + let MaskShape::Object { hint, seg: None, edge, .. } = &c.shape else { return }; + if !hint.is_empty() { + return; + } + let edge = *edge; + let seg = match found { + Some(seg) => seg, + // neither a person nor an animal: the object at the point + None if self.segmenter.background => { + if let Err(e) = self.segment_clicks_later(photo, mask, comp, vec![point], Vec::new()) { + polled.messages.push(e); + } + return; + } + None => { + match self.segment_clicks(photo, &clicks_of(&[point], &[])) { + Ok(seg) => { + c.shape = MaskShape::Object { hint: vec![point], exclude: Vec::new(), seg: Some(seg), detail: Vec::new(), edge } + } + Err(e) => return polled.messages.push(e), + } + if let Err(e) = self.set_develop(photo, d, "Find Subject") { + polled.messages.push(e.to_string()); + } else { + polled.changed = true; + } + return; + } + }; + let text = lightcraft_segment::SUBJECT_CONCEPTS.join(", "); + c.shape = MaskShape::Prompt { text, seg: Some(seg), detail: Vec::new(), edge }; + match self.set_develop(photo, d, "Find Subject") { + Ok(()) => polled.changed = true, + Err(e) => polled.messages.push(e.to_string()), + } + } Tag::Detail { photo, mask, comp, shape } => { let patch = match o.result { Ok(Some(p)) => p, @@ -750,6 +827,47 @@ mod tests { assert!(s.segment_poll().messages.is_empty(), "once"); } + /// Find Subject's result: people / animals found become a Describe selection of the waiting + /// Object (one undo step), not after it was clicked meanwhile; nothing found queues the object + /// at the point (here: no model, so a message, and the mask still waits). + #[cfg(feature = "sam")] + #[test] + fn find_subject_results_fill_the_waiting_mask() { + use lightcraft_develop::{Mask, MaskComponent, MaskOp}; + let mut s = Session::with_demo(); + s.segmenter.dir = Some(std::env::temp_dir().join(format!("lc-no-sam3-subject-{}", std::process::id()))); + let id = s.active().unwrap(); + let waiting = || MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }; + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.masks.push(Mask { + id: 3, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: waiting() }], + ..Default::default() + }); + s.set_develop(id, d, "test").unwrap(); + let point = Point::new(0.4, 0.5); + let send = |s: &mut Session, result| { + let tag = Tag::Subject { photo: id, mask: 3, comp: 0, point }; + s.segmenter.results.0.send(worker::Outcome { tag, result, superseded: false, notice: None }).unwrap(); + s.segment_poll() + }; + let shape = |s: &Session| s.develop_of(id).unwrap().masks[0].components[0].shape.clone(); + // nothing found, no model to click with: said so, and the mask still waits + let polled = send(&mut s, Ok(None)); + assert!(!polled.messages.is_empty() && shape(&s) == waiting(), "{:?}", polled.messages); + // people found: a Describe selection of them + let polled = send(&mut s, Ok(Some(seg_box(20, 5, 5, 15, 15)))); + assert!(polled.changed && polled.messages.is_empty()); + let MaskShape::Prompt { text, seg: Some(_), .. } = shape(&s) else { panic!("{:?}", shape(&s)) }; + assert_eq!(text, "person, animal"); + // a result for a mask that changed meanwhile is dropped + let before = shape(&s); + assert!(!send(&mut s, Ok(Some(seg_box(20, 0, 0, 4, 4)))).changed); + assert_eq!(shape(&s), before); + // an error reaches the user + assert_eq!(send(&mut s, Err("boom".into())).messages, vec!["boom".to_string()]); + } + fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask { let l: Vec = (0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect(); diff --git a/crates/engine/src/segment/worker.rs b/crates/engine/src/segment/worker.rs index efbb1c21c4..1e4bf6e8d9 100644 --- a/crates/engine/src/segment/worker.rs +++ b/crates/engine/src/segment/worker.rs @@ -42,6 +42,12 @@ pub enum Kind { Prepare, Clicks(Vec), Text(String), + /// The photo's subject among its people and animals (`Sam3::segment_subject`); `focus`: where + /// the camera focused (normalized), when the file records it. No result: the subject is + /// something else. + Subject { + focus: Option<(f32, f32)>, + }, /// A zoomed-in pass over `region` (normalized) of the photo, rendered by the job's `render`. Detail { prompt: Prompt, @@ -420,6 +426,7 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str) let operation = match std::mem::replace(&mut job.kind, Kind::Prepare) { Kind::Prepare => Operation::Prepare, Kind::Text(text) => Operation::Text(text), + Kind::Subject { focus } => Operation::Subject(focus.map(|(x, y)| [x, y])), Kind::Clicks(clicks) => Operation::Clicks(clicks.iter().map(|c| [c.x, c.y, if c.positive { 1.0 } else { 0.0 }]).collect()), Kind::Detail { prompt, region: r } => { region = Some(r); @@ -480,6 +487,11 @@ fn run_job(state: &mut State, shared: &Shared, job: &mut Job, placement: Placeme let (m, enc) = encoded(state, shared, job, placement)?; Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l))) } + Kind::Subject { focus } => { + let (m, enc) = encoded(state, shared, job, placement)?; + // (the reference score threshold, as Describe uses) + Ok(m.segment_subject(enc, focus, 0.5)?.map(|p| SegMask::from_logits(side, &p.logits))) + } #[cfg(test)] #[allow(clippy::panic)] Kind::Panic => panic!("a test panic"), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 632814334d..64641f9edf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -385,6 +385,84 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() { assert_eq!(s.catalog.to_snapshot(), snapshot); } +/// `lensBlur.brushStroke` adds a stroke to the Focus or the Blur brush (clamped, undoable); hostile +/// input is an error; `lensBlur.brushReset` clears one brush or both. +#[test] +fn lens_blur_brush_strokes_and_reset() { + let mut s = demo(); + let id = s.active().unwrap(); + s.execute( + "lensBlur.brushStroke", + &json!({"mode": "focus", "points": [[0.2, 0.3], [0.25, 0.3]], "size": 9.0, "amount": 40, "feather": f64::NAN, "autoMask": true}), + ) + .unwrap(); + s.execute("lensBlur.brushStroke", &json!({"mode": "blur", "points": [[0.7, 0.6]]})).unwrap(); + let d = s.develop_of(id).unwrap(); + let (f, b) = (&d.lens_blur.focus_strokes, &d.lens_blur.blur_strokes); + assert_eq!((f.len(), b.len()), (1, 1)); + assert_eq!(f[0].points.len(), 2); + assert!(f[0].size == 0.5 && f[0].density == 40.0 && f[0].feather == lightcraft_develop::BrushStroke::default().feather && f[0].auto_mask); + for bad in [ + json!({"mode": "sideways", "points": [[0.5, 0.5]]}), + json!({"mode": "focus", "points": []}), + json!({"mode": "focus"}), + json!({"mode": "blur", "points": [[0.5, 0.5]], "pressure": [0.5, 0.5]}), + ] { + assert!(s.execute("lensBlur.brushStroke", &bad).is_err(), "{bad}"); + } + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.develop_of(id).unwrap().lens_blur.blur_strokes.is_empty(), "a stroke is one undo step"); + s.execute("lensBlur.brushStroke", &json!({"mode": "blur", "points": [[0.7, 0.6]]})).unwrap(); + s.execute("lensBlur.brushReset", &json!({"mode": "blur"})).unwrap(); + let d = s.develop_of(id).unwrap(); + assert!(d.lens_blur.blur_strokes.is_empty() && d.lens_blur.focus_strokes.len() == 1); + assert!(s.execute("lensBlur.brushReset", &json!({"mode": "nope"})).is_err()); + s.execute("lensBlur.brushReset", &json!({})).unwrap(); + assert!(s.develop_of(id).unwrap().lens_blur.focus_strokes.is_empty()); +} + +/// `lensBlur.findSubject` without the SAM 3 model (or in a build without AI masks) is an error that +/// leaves the photo as it was: no empty mask, the blur not switched on. +#[test] +fn find_subject_without_the_model_changes_nothing() { + let mut s = demo(); + let id = s.active().unwrap(); + // (never the machine's own model: a folder that has none) + s.segmenter.dir = Some(std::env::temp_dir().join(format!("lc-no-sam3-{}", std::process::id()))); + let before = s.develop_of(id).unwrap(); + let e = s.execute("lensBlur.findSubject", &json!({})).unwrap_err(); + assert!(e.to_string().contains("SAM 3") || e.to_string().contains("AI masks"), "{e}"); + let after = s.develop_of(id).unwrap(); + assert!(after.masks.is_empty() && !after.lens_blur.enabled); + assert_eq!(*before, *after); +} + +/// Deleting the mask that is the Lens Blur focus clears the focus, so a new mask that gets the same +/// id doesn't become the focus; undo brings both back. +#[test] +fn deleting_the_focus_mask_clears_the_lens_blur_focus() { + let mut s = demo(); + let id = s.active().unwrap(); + let radial = json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.2, "ry": 0.2}); + s.execute("mask.add", &radial).unwrap(); + let mid = s.develop_of(id).unwrap().masks[0].id; + s.execute("develop.merge", &json!({"settings": {"lens_blur": {"enabled": true, "amount": 50, "focus_mask": mid}}})).unwrap(); + s.execute("mask.delete", &json!({"id": mid})).unwrap(); + assert_eq!(s.develop_of(id).unwrap().lens_blur.focus_mask, None); + // a new mask takes the freed id, and stays an ordinary mask + s.execute("mask.add", &radial).unwrap(); + let d = s.develop_of(id).unwrap(); + assert_eq!(d.masks[0].id, mid); + assert_eq!(d.lens_blur.focus_mask, None); + // undoing the add and the delete brings the focus back with its mask + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.develop_of(id).unwrap().lens_blur.focus_mask, Some(mid)); + // other mask edits keep it + s.execute("mask.adjust", &json!({"values": {"exposure": 0.5}})).unwrap(); + assert_eq!(s.develop_of(id).unwrap().lens_blur.focus_mask, Some(mid)); +} + /// `mask.brushStroke {connect: true}` (a ⇧-click) starts from the last point of the mask's last stroke: one straight /// stroke when the brush is unchanged, pieces whose size, feather and flow step from the last stroke's to the new /// values when it changed, and a plain dab when there is nothing to connect to. diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index bc079edb11..a392ee6ee1 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -1054,7 +1054,14 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho eyed } }; - denoise(cx, img, plan, colour_noise) + let img = denoise(cx, img, plan, colour_noise); + if !lightcraft_pipeline::lens_blur::renders(s) { + return img; + } + // Lens Blur (global or a mask's): on the CPU, like the CPU renderer's (`lens_blur::apply`) + let mut host_img = cx.read_rgb(&img, w, h); + lightcraft_pipeline::lens_blur::apply_with(&mut host_img, s, &plan.frame, plan.px_per_long, plan.mattes.as_deref(), plan.lens_subject.as_deref()); + cx.gpu.upload(rgb_words(&host_img)) } /// Defringe (`optics::defringe`) of the white-balanced image. diff --git a/crates/pipeline/src/lens_blur.rs b/crates/pipeline/src/lens_blur.rs new file mode 100644 index 0000000000..f518eb5de7 --- /dev/null +++ b/crates/pipeline/src/lens_blur.rs @@ -0,0 +1,1109 @@ +//! Lens Blur: a shallow depth of field added after the fact. +//! +//! Without a depth map, the scene has two depths: what is in focus (the photo's subject, or the +//! selection of the mask [`LensBlur::focus_mask`](lightcraft_develop::LensBlur) names) and +//! everything else. A pixel's circle of confusion is `R · (1 − focus)`, `R` set by Blur Amount +//! relative to the long edge, so previews and exports match. +//! +//! The blur is gathered from [`LEVELS`] disc blurs of growing radius, blended per pixel by its +//! circle of confusion. Each disc blur runs on a grid coarse enough that the disc spans about +//! [`DISC_CELLS`] cells (its cost doesn't grow with the radius) and is a normalized convolution +//! weighted by how far out of focus each pixel is, so the sharp subject doesn't bleed into the +//! blurred background around it (no halo). Boost brightens highlights before they are spread, so +//! they turn into bright discs (bokeh), as a lens does with scene-linear light. +//! +//! Brush Refinement paints the focus by hand: Focus brush strokes stay sharp, Blur brush strokes +//! are blurred whatever the focus says. +//! +//! A mask can blur too (Lens Blur among its local adjustments): there the mask's strength is the +//! circle of confusion, so what it fully selects gets the whole blur, its feathered edge less, and +//! what it leaves out stays sharp and doesn't bleed into the blur. +//! +//! Amount is capped at 3.5% of the output's long edge. The disc convolution runs on a grid whose +//! disc radius is at most [`DISC_CELLS`] cells and whose size is capped by [`MAX_CELLS`] per level, +//! so increasing the output resolution or blur radius does not make the convolution kernel grow. +//! The full-resolution passes remain linear in the output pixel count. +//! +//! It runs on the scene-linear image (white-balanced and denoised, before exposure: a blur is +//! linear, so exposure commutes with it), on the CPU, for both renderers. + +use lightcraft_develop::{BrushStroke, DevelopSettings, Mask, MaskShape}; +use lightcraft_raster::{Plane, Rgb32f}; + +use crate::geometry::Frame; +use crate::masks::{self, Mattes}; + +/// The largest circle of confusion (Blur Amount 100), as a fraction of the long edge. +pub const MAX_RADIUS: f64 = 0.035; +/// How far from the focus the blur reaches its full size at Transition 100, as a fraction of the +/// long edge (a stand-in for depth: what is near the subject in the picture is usually near it in +/// the scene too, as a lens renders a floor or a wall running away from it). +pub const MAX_TRANSITION: f64 = 0.15; +/// The blur right at the focus's edge, as a share of the full blur (with a transition): the +/// background around a subject is a little soft at once, not cut out sharp. +const EDGE_BLUR: f32 = 0.1; +/// The feather (0..100) of a fixed focus shape's edge ([`lightcraft_develop::FocusShape`]). +const SHAPE_FEATHER: f64 = 40.0; +/// Most cells along the long edge of the grid the distance to the focus is measured on. +const DISTANCE_CELLS: usize = 384; +/// Disc blurs the per-pixel circle of confusion is interpolated between. +const LEVELS: usize = 4; +/// Radius, in grid cells, a level's disc blur works at. +const DISC_CELLS: f32 = 8.0; +/// Most cells a level's grid holds (bounds the memory of a full-size export). +const MAX_CELLS: usize = 3_000_000; +/// Weight of in-focus pixels in the blurred levels: enough to keep a level defined where only +/// in-focus pixels are near, too little to bleed them into the background. +const FOCUS_WEIGHT: f32 = 1e-4; +/// The brightest highlights grow this much brighter at Boost 100 before they are spread. +const BOOST_GAIN: f32 = 4.0; + +/// Whether `s` renders a lens blur (switched on, with some amount, and its section's eye open). +pub fn active(s: &DevelopSettings) -> bool { + let b = &s.lens_blur; + b.enabled && b.amount.is_finite() && b.amount > 0.0 && s.section_enabled("lensBlur") +} + +/// Whether [`apply`] changes anything: the global lens blur, or a mask's. +pub fn renders(s: &DevelopSettings) -> bool { + active(s) || blurring_masks(s).next().is_some() +} + +/// The largest circle of confusion (radius in output pixels) at `px_per_long` output pixels per +/// long edge; 0 when [`active`] is false. +pub fn radius_px(s: &DevelopSettings, px_per_long: f64) -> f32 { + if !active(s) { + return 0.0; + } + let r = s.lens_blur.amount.clamp(0.0, 100.0) / 100.0 * MAX_RADIUS * px_per_long; + if r.is_finite() { r.max(0.0) as f32 } else { 0.0 } +} + +/// The masks that blur what they select, or what they leave out (the Lens Blur block among a +/// mask's local adjustments): shown, with components, the block on and some blur. +fn blurring_masks(s: &DevelopSettings) -> impl Iterator { + s.masks + .iter() + .filter(|m| m.visible && !m.components.is_empty() && !m.adjust.lens_blur_off && m.adjust.lens_blur.is_finite() && m.adjust.lens_blur > 0.0) +} + +/// The circle of confusion (radius in output pixels) where mask `m` fully selects. +fn mask_radius_px(m: &Mask, px_per_long: f64) -> f32 { + let r = m.adjust.lens_blur.clamp(0.0, 100.0) / 100.0 * MAX_RADIUS * px_per_long; + if r.is_finite() { r.max(0.0) as f32 } else { 0.0 } +} + +/// The distance from the focus (output pixels) at which the blur is full; 0: a sharp cut. +pub fn transition_px(s: &DevelopSettings, px_per_long: f64) -> f32 { + if !active(s) { + return 0.0; + } + transition_of(s.lens_blur.transition, px_per_long) +} + +/// [`transition_px`] for a Transition slider value `t` (0..100). +fn transition_of(t: f64, px_per_long: f64) -> f32 { + if !t.is_finite() || t <= 0.0 { + return 0.0; + } + let d = t.min(100.0) / 100.0 * MAX_TRANSITION * px_per_long; + if d.is_finite() { d.max(0.0) as f32 } else { 0.0 } +} + +/// A Boost slider value (0..100) as 0..1. +fn boost_of(b: f64) -> f32 { + if b.is_finite() { (b.clamp(0.0, 100.0) / 100.0) as f32 } else { 0.0 } +} + +/// The farthest any lens blur of `s` reads (output pixels): the global one's (its blur, and how far +/// it looks for the focus) or a mask's. +pub fn reach_px(s: &DevelopSettings, px_per_long: f64) -> f32 { + let global = radius_px(s, px_per_long) + transition_px(s, px_per_long); + blurring_masks(s).map(|m| mask_radius_px(m, px_per_long) + transition_of(m.adjust.lens_blur_transition, px_per_long)).fold(global, f32::max) +} + +/// `win` (a window of the `full_w × full_h` output) grown by the blur's reach, so a zoomed view +/// blurs its edges with the pixels around them, as the whole render does (the render is cut back +/// to `win`, see [`crate::Plan::keep`]). +pub fn window_for_reads(s: &DevelopSettings, full_w: usize, full_h: usize, px_per_long: f64, win: crate::PixelWindow) -> crate::PixelWindow { + let r = reach_px(s, px_per_long); + if r <= 0.0 { + return win; + } + let m = (r * 1.25).ceil() as usize + 2; + let (x0, y0) = (win.x.saturating_sub(m), win.y.saturating_sub(m)); + let x1 = win.x.saturating_add(win.w).saturating_add(m).min(full_w.max(1)); + let y1 = win.y.saturating_add(win.h).saturating_add(m).min(full_h.max(1)); + crate::PixelWindow { x: x0, y: y0, w: x1.saturating_sub(x0).max(1), h: y1.saturating_sub(y0).max(1) } +} + +/// What [`apply`] reads from `s`, for render cache keys (empty when no lens blur renders). +pub fn key(s: &DevelopSettings) -> String { + let mut k = String::new(); + if active(s) { + k = match focus_mask(s) { + Some(m) => format!("{:?}|{m:?}", s.lens_blur), + None => format!("{:?}", s.lens_blur), + }; + } + for m in blurring_masks(s) { + k.push_str(&format!("|{m:?}")); + } + // a mask's luminance / colour ranges select on the exposed image + if !k.is_empty() { + k.push_str(&format!("|{}", s.light.exposure.to_bits())); + } + k +} + +/// The mask that says what is in focus, if the settings name one that exists and selects something. +/// An AI mask whose selection is still being computed (Find Subject on a slow machine) doesn't +/// count yet: until it is, the subject estimate stays in focus rather than nothing. +fn focus_mask(s: &DevelopSettings) -> Option<&Mask> { + let id = s.lens_blur.focus_mask?; + s.masks.iter().find(|m| m.id == id && !m.components.is_empty() && !awaiting_ai(m)) +} + +/// Whether every component of `m` is an AI selection (Object / Describe) not computed yet. +pub fn awaiting_ai(m: &Mask) -> bool { + m.components.iter().all(|c| match &c.shape { + MaskShape::Object { seg, detail, .. } | MaskShape::Prompt { seg, detail, .. } => seg.is_none() && detail.is_empty(), + _ => false, + }) +} + +/// Smoothstep: 0 below `e0`, 1 above `e1`, a smooth S-curve between. +#[inline] +fn smooth(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// What is in focus, 0..1 per pixel of `img` (scene-linear, before exposure, output resolution). +pub fn focus(img: &Rgb32f, s: &DevelopSettings, frame: &Frame, mattes: Option<&Mattes>) -> Plane { + focus_with(img, s, frame, mattes, None) +} + +/// [`focus`], taking the automatic subject estimate from `subject` (made from the whole photo, +/// see [`subject_estimate`]) when there is one, so a window of the output gets the same focus as +/// the whole render. +fn focus_with(img: &Rgb32f, s: &DevelopSettings, frame: &Frame, mattes: Option<&Mattes>, subject: Option<&SubjectEstimate>) -> Plane { + let (w, h) = (img.width, img.height); + let log_l = img.map(crate::local::log_lum); + let mut a = match (focus_mask(s), s.lens_blur.focus_area(s.crop_center())) { + (Some(m), _) => masks::evaluate_one(m, frame, w, h, img, &log_l, s.light.exposure as f32, mattes), + // a fixed shape: where it was moved to, else the middle of the crop (in photo + // coordinates, so it stays put in a zoomed view) + (None, Some(area)) => { + let shape = MaskShape::Radial { center: area.center, rx: area.rx, ry: area.ry, angle: 0.0, feather: SHAPE_FEATHER, invert: false }; + masks::shape_alpha(&shape, frame, w, h, img, &log_l, 0.0, mattes) + } + (None, None) if let Some(est) = subject => est.sample(frame, w, h), + (None, None) => { + let mut a = masks::shape_alpha(&MaskShape::Subject, frame, w, h, img, &log_l, 0.0, mattes); + // the saliency heuristic gives a flat centre about 0.5: in focus too (person mattes + // are 0 or 1 and stay so) + a.data.iter_mut().for_each(|v| *v = smooth(0.25, 0.55, *v)); + a + } + }; + // Brush Refinement: the Focus brush adds focus, the Blur brush takes it away (last word) + let b = &s.lens_blur; + let brush = |strokes: &[BrushStroke]| { + (!strokes.is_empty()).then(|| masks::shape_alpha(&MaskShape::Brush { strokes: strokes.to_vec() }, frame, w, h, img, &log_l, 0.0, mattes)) + }; + if let Some(f) = brush(&b.focus_strokes) { + a.data.iter_mut().zip(&f.data).for_each(|(v, f)| *v = v.max(*f)); + } + if let Some(bl) = brush(&b.blur_strokes) { + a.data.iter_mut().zip(&bl.data).for_each(|(v, b)| *v *= 1.0 - b.clamp(0.0, 1.0)); + } + a.data.iter_mut().for_each(|v| *v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + a +} + +/// Long edge of the small render [`subject_point`] looks at. +const SUBJECT_EDGE: usize = 256; + +/// Long edge of the small render [`subject_estimate`] makes of the whole photo. +const ESTIMATE_EDGE: usize = 384; + +/// The automatic subject estimate (the saliency heuristic, for a photo without a person matte) +/// made once from the whole, uncropped photo at [`ESTIMATE_EDGE`]: every render of the photo, +/// whole or a window of it, samples the same estimate, so a zoomed view keeps the focus the +/// whole render has (the heuristic reads its neighbourhood, which a window cuts off). +#[derive(Debug)] +pub struct SubjectEstimate { + plane: Plane, + frame: Frame, +} + +impl SubjectEstimate { + /// The estimate at each pixel of a `w × h` output of `frame` (bilinear). + fn sample(&self, frame: &Frame, w: usize, h: usize) -> Plane { + let (pw, ph) = (self.plane.width, self.plane.height); + let mut out = Plane::new(w, h); + if pw == 0 || ph == 0 || self.plane.data.len() != pw * ph { + return out; + } + let to_norm = frame.out_to_norm(w, h); + let to_proxy = self.frame.norm_to_out(pw, ph); + let at = |x: usize, y: usize| self.plane.data.get(y.min(ph - 1) * pw + x.min(pw - 1)).copied().unwrap_or(0.0); + for (i, v) in out.data.iter_mut().enumerate() { + let n = to_norm.apply(lightcraft_geom::Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5)); + let p = to_proxy.apply(n); + let (fx, fy) = ((p.x - 0.5).clamp(0.0, (pw - 1) as f64), (p.y - 0.5).clamp(0.0, (ph - 1) as f64)); + if !(fx.is_finite() && fy.is_finite()) { + continue; + } + let (x0, y0) = (fx as usize, fy as usize); + let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let top = at(x0, y0) + (at(x0 + 1, y0) - at(x0, y0)) * tx; + let bot = at(x0, y0 + 1) + (at(x0 + 1, y0 + 1) - at(x0, y0 + 1)) * tx; + *v = top + (bot - top) * ty; + } + out + } +} + +/// The [`SubjectEstimate`] the lens blur of `s` focuses on, when it focuses on the automatic +/// subject estimate (switched on, no focus mask, no fixed shape) of a photo without a person +/// matte; `None` otherwise (the focus then needs no whole-photo data). +pub fn subject_estimate(src: &Rgb32f, s: &DevelopSettings, mattes: Option<&Mattes>) -> Option { + if !active(s) || focus_mask(s).is_some() || s.lens_blur.focus_area(s.crop_center()).is_some() || mattes.is_some_and(Mattes::selects_subject) { + return None; + } + if src.width == 0 || src.height == 0 || src.data.len() != src.width.saturating_mul(src.height) { + return None; + } + let frame = Frame::new(src.width, src.height, s, false); + let (w, h) = frame.fit(ESTIMATE_EDGE, ESTIMATE_EDGE); + if w == 0 || h == 0 { + return None; + } + let img = frame.sample(src, w, h); + let log_l = img.map(crate::local::log_lum); + let mut plane = masks::shape_alpha(&MaskShape::Subject, &frame, w, h, &img, &log_l, 0.0, None); + // the saliency heuristic gives a flat centre about 0.5: in focus too + plane.data.iter_mut().for_each(|v| *v = smooth(0.25, 0.55, *v)); + Some(SubjectEstimate { plane, frame }) +} + +/// Where the photo's subject most likely is, for a photo whose camera recorded no focus point: the +/// peak of the subject estimate (person mattes, else the saliency heuristic) over the whole, +/// uncropped photo, in normalized mask coordinates. `None` for an empty source. +pub fn subject_point(src: &Rgb32f, s: &DevelopSettings, mattes: Option<&Mattes>) -> Option { + if src.width == 0 || src.height == 0 || src.data.len() != src.width.saturating_mul(src.height) { + return None; + } + let frame = Frame::new(src.width, src.height, s, false); + let (w, h) = frame.fit(SUBJECT_EDGE, SUBJECT_EDGE); + if w == 0 || h == 0 { + return None; + } + let img = frame.sample(src, w, h); + let log_l = img.map(crate::local::log_lum); + let a = masks::shape_alpha(&MaskShape::Subject, &frame, w, h, &img, &log_l, 0.0, mattes); + let (i, _) = a.data.iter().enumerate().filter(|(_, v)| v.is_finite()).max_by(|x, y| x.1.total_cmp(y.1))?; + let n = frame.out_to_norm(w, h).apply(lightcraft_geom::Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5)); + (n.x.is_finite() && n.y.is_finite()).then(|| lightcraft_geom::Point::new(n.x.clamp(0.0, 1.0), n.y.clamp(0.0, 1.0))) +} + +/// Blur what is out of focus in `img` (scene-linear, before exposure, at output resolution; +/// `frame` and `px_per_long` describe that output), then what each mask with a Lens Blur +/// adjustment selects (in mask order). Does nothing when no lens blur renders. +pub fn apply(img: &mut Rgb32f, s: &DevelopSettings, frame: &Frame, px_per_long: f64, mattes: Option<&Mattes>) { + apply_with(img, s, frame, px_per_long, mattes, None); +} + +/// [`apply`] with the photo's [`SubjectEstimate`] (renders pass [`crate::Plan::lens_subject`]). +pub fn apply_with( + img: &mut Rgb32f, + s: &DevelopSettings, + frame: &Frame, + px_per_long: f64, + mattes: Option<&Mattes>, + subject: Option<&SubjectEstimate>, +) { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 || img.data.len() != w.saturating_mul(h) { + return; + } + // a zoomed view's place in the whole output: its coarse grids line up with the whole render's + let at = |v: f64| if v.is_finite() && v > 0.0 { v.round() as usize } else { 0 }; + let origin = frame.view.map_or((0, 0), |v| (at(v.x), at(v.y))); + let r = radius_px(s, px_per_long); + if r >= 0.5 { + let focus = crate::timed("lens blur focus", || focus_with(img, s, frame, mattes, subject)); + if focus.data.len() == img.data.len() { + // the blur grows with the distance from what is in focus, as with depth + let ramp = crate::timed("lens blur transition", || transition_ramp(&focus, transition_px(s, px_per_long), px_per_long, origin)); + let coc: Vec = focus.data.iter().enumerate().map(|(i, f)| (1.0 - f) * ramp.as_ref().map_or(1.0, |r| r.at(i))).collect(); + blur(img, &coc, r, boost_of(s.lens_blur.boost), origin); + } + } + let ev = s.light.exposure as f32; + for m in blurring_masks(s) { + let r = mask_radius_px(m, px_per_long); + if r < 0.5 { + continue; + } + // the mask's strength (its Amount included) is how far out of focus a pixel is; blurring + // the outside, it is how far the mask stays below its Amount + let log_l = img.map(crate::local::log_lum); + let alpha = crate::timed("lens blur mask", || masks::evaluate_one(m, frame, w, h, img, &log_l, ev, mattes)); + if alpha.data.len() != img.data.len() { + continue; + } + let a = &m.adjust; + let top = if a.amount.is_finite() { (a.amount / 100.0).clamp(0.0, 1.0) as f32 } else { 0.0 }; + let mut coc: Vec = alpha + .data + .iter() + .map(|v| { + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + if a.lens_blur_outside { (top - v).max(0.0) } else { v } + }) + .collect(); + // the blur grows away from what stays sharp, as the global one's does + let sharp = Plane { width: w, height: h, data: coc.iter().map(|c| 1.0 - c).collect() }; + if let Some(ramp) = transition_ramp(&sharp, transition_of(a.lens_blur_transition, px_per_long), px_per_long, origin) { + coc.iter_mut().enumerate().for_each(|(i, c)| *c *= ramp.at(i)); + } + blur(img, &coc, r, boost_of(a.lens_blur_boost), origin); + } +} + +/// How much of the full blur each pixel gets for its distance from the focus: [`EDGE_BLUR`] at +/// the focus's edge, growing smoothly to all of it `d` pixels away. +struct Ramp { + /// Distance from the focus, in grid cells. + dist: Plane, + /// Pixels per cell. + g: usize, + w: usize, + d: f32, + /// Where pixel (0, 0) sits in the grid (the grid follows the whole output's cells). + shift: (usize, usize), +} + +impl Ramp { + /// The share of the full blur pixel `i` (row-major in the window) gets. + fn at(&self, i: usize) -> f32 { + let (x, y) = (i % self.w.max(1) + self.shift.0, i / self.w.max(1) + self.shift.1); + let g = self.g as f32; + // bilinear in the grid, cell centres at +0.5 + let (fx, fy) = (((x as f32 + 0.5) / g - 0.5).max(0.0), ((y as f32 + 0.5) / g - 0.5).max(0.0)); + let (gw, gh) = (self.dist.width, self.dist.height); + let (x0, y0) = ((fx as usize).min(gw - 1), (fy as usize).min(gh - 1)); + let (x1, y1) = ((x0 + 1).min(gw - 1), (y0 + 1).min(gh - 1)); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let v = |x: usize, y: usize| self.dist.data.get(y * gw + x).copied().unwrap_or(f32::MAX); + let top = v(x0, y0) + (v(x1, y0) - v(x0, y0)) * tx; + let bot = v(x0, y1) + (v(x1, y1) - v(x0, y1)) * tx; + let px = (top + (bot - top) * ty) * g; + EDGE_BLUR + (1.0 - EDGE_BLUR) * smooth(0.0, self.d, px) + } +} + +/// The [`Ramp`] for `focus` over `d` pixels; `None` for a sharp cut (`d` 0) or nothing in focus. +/// Distances are chamfer distances (steps of 1 and √2 cells) on a grid of about +/// [`DISTANCE_CELLS`] along the photo's long edge (`px_per_long`: the cell size follows the +/// photo's scale, not the rendered window's, and its cells are the whole output's: `origin` is +/// where a window's pixel (0, 0) sits in it, so a zoomed view measures as the whole render does): +/// what is in focus are the cells mostly in focus. +fn transition_ramp(focus: &Plane, d: f32, px_per_long: f64, origin: (usize, usize)) -> Option { + let (w, h) = (focus.width, focus.height); + // (a NaN distance is no transition) + if d.is_nan() || d <= 0.5 || w == 0 || h == 0 || focus.data.len() != w * h { + return None; + } + let per_cell = px_per_long / DISTANCE_CELLS as f64; + let g = if per_cell.is_finite() { (per_cell.ceil() as usize).clamp(1, w.max(h)) } else { 1 }; + let shift = (origin.0 % g, origin.1 % g); + // the grid covers the window shifted into the whole output's cells + let (gw, gh) = ((w + shift.0).div_ceil(g), (h + shift.1).div_ceil(g)); + let mut dist = Plane::new(gw, gh); + let mut any = false; + for cy in 0..gh { + for cx in 0..gw { + let (mut sum, mut n) = (0.0f32, 0usize); + for vy in cy * g..((cy + 1) * g).min(h + shift.1) { + for vx in cx * g..((cx + 1) * g).min(w + shift.0) { + let (Some(x), Some(y)) = (vx.checked_sub(shift.0), vy.checked_sub(shift.1)) else { continue }; + sum += focus.data.get(y * w + x).copied().unwrap_or(0.0); + n += 1; + } + } + let inside = n > 0 && sum / n as f32 > 0.5; + any |= inside; + if let Some(c) = dist.data.get_mut(cy * gw + cx) { + *c = if inside { 0.0 } else { f32::MAX }; + } + } + } + if !any { + return None; + } + let diag = std::f32::consts::SQRT_2; + let at = |d: &Plane, x: isize, y: isize| { + if x < 0 || y < 0 || x >= gw as isize || y >= gh as isize { + f32::MAX + } else { + d.data.get(y as usize * gw + x as usize).copied().unwrap_or(f32::MAX) + } + }; + // forward then backward chamfer passes + for y in 0..gh as isize { + for x in 0..gw as isize { + let c = at(&dist, x, y) + .min(at(&dist, x - 1, y) + 1.0) + .min(at(&dist, x, y - 1) + 1.0) + .min(at(&dist, x - 1, y - 1) + diag) + .min(at(&dist, x + 1, y - 1) + diag); + if let Some(v) = dist.data.get_mut(y as usize * gw + x as usize) { + *v = c; + } + } + } + for y in (0..gh as isize).rev() { + for x in (0..gw as isize).rev() { + let c = at(&dist, x, y) + .min(at(&dist, x + 1, y) + 1.0) + .min(at(&dist, x, y + 1) + 1.0) + .min(at(&dist, x + 1, y + 1) + diag) + .min(at(&dist, x - 1, y + 1) + diag); + if let Some(v) = dist.data.get_mut(y as usize * gw + x as usize) { + *v = c; + } + } + } + Some(Ramp { dist, g, w, d, shift }) +} + +/// Blur `img` by a circle of confusion `coc · r` pixels per pixel (`coc` 0..1, one per pixel), +/// highlights brightened by `boost` (0..1) before they are spread. +/// `origin`: where `img`'s pixel (0, 0) sits in the whole output (a zoomed view's window), so the +/// coarse grids line up with the whole render's. +fn blur(img: &mut Rgb32f, coc: &[f32], r: f32, boost: f32, origin: (usize, usize)) { + let w = img.width; + // highlights brightened before they are spread (the bokeh), the rest as it is + let gain = |c: [f32; 3]| 1.0 + BOOST_GAIN * boost * smooth(0.5, 1.0, lightcraft_color::luminance_2020(c)); + let levels: Vec = (1..=LEVELS).map(|k| crate::timed("lens blur level", || level(img, coc, k, r, &gain, origin))).collect(); + // each pixel between the two levels around its circle of confusion (level 0: the image) + let src = img.clone(); + crate::for_rows(&mut img.data, w, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let i = y * w + x; + let t = coc.get(i).copied().unwrap_or(0.0) * LEVELS as f32; + let k = (t.floor().max(0.0) as usize).min(LEVELS - 1); + let fr = (t - k as f32).clamp(0.0, 1.0); + let at = |k: usize| match k { + 0 => src.data.get(i).copied().unwrap_or(*px), + k => levels.get(k - 1).map_or(*px, |l| l.sample(x, y)), + }; + let (a, b) = (at(k), at(k + 1)); + *px = std::array::from_fn(|c| a[c] + (b[c] - a[c]) * fr); + } + }); +} + +/// One disc blur, on a grid `f` times coarser than the image. +struct Level { + img: Rgb32f, + f: usize, + /// Where image pixel (0, 0) sits on the grid's pixels (the whole output's cells). + shift: (usize, usize), +} + +impl Level { + /// The blurred value at image pixel `(x, y)`. + fn sample(&self, x: usize, y: usize) -> [f32; 3] { + let f = self.f as f32; + self.img.sample_bilinear(((x + self.shift.0) as f32 + 0.5) / f, ((y + self.shift.1) as f32 + 0.5) / f) + } +} + +/// Level `k` of [`LEVELS`] (radius `k / LEVELS · r` pixels): the disc blur of the pixels at least +/// that far out of focus. +fn level(img: &Rgb32f, coc: &[f32], k: usize, r: f32, gain: &(impl Fn([f32; 3]) -> f32 + Sync), origin: (usize, usize)) -> Level { + let (w, h) = (img.width, img.height); + let radius = r * k as f32 / LEVELS as f32; + let (f, shift, lw, lh) = level_grid(w, h, radius, origin); + let (grid_w, grid_h) = (w.saturating_add(shift.0), h.saturating_add(shift.1)); + // a pixel counts in this level when its own circle of confusion reaches it + let (lo, hi) = ((k - 1) as f32 / LEVELS as f32, k as f32 / LEVELS as f32); + let mut cells = vec![[0f32; 4]; lw * lh]; + crate::for_rows(&mut cells, lw, |cy, row| { + for (cx, cell) in row.iter_mut().enumerate() { + let mut acc = [0f32; 4]; + for vy in cy * f..((cy + 1) * f).min(grid_h) { + for vx in cx * f..((cx + 1) * f).min(grid_w) { + let (Some(x), Some(y)) = (vx.checked_sub(shift.0), vy.checked_sub(shift.1)) else { continue }; + let i = y * w + x; + let (Some(c), Some(d)) = (img.data.get(i), coc.get(i)) else { continue }; + // (a non-finite pixel would spread over the whole disc: left out) + if !c.iter().all(|v| v.is_finite()) { + continue; + } + let wt = FOCUS_WEIGHT + smooth(lo, hi, *d); + let g = gain(*c) * wt; + acc = [acc[0] + c[0] * g, acc[1] + c[1] * g, acc[2] + c[2] * g, acc[3] + wt]; + } + } + *cell = acc; + } + }); + Level { img: disc(&cells, lw, lh, radius / f as f32), f, shift } +} + +/// Grid scale and dimensions for one disc level, bounded even when a window's grid is offset. +fn level_grid(w: usize, h: usize, radius: f32, origin: (usize, usize)) -> (usize, (usize, usize), usize, usize) { + let at_least = ((w as f64 * h as f64) / MAX_CELLS as f64).sqrt().ceil() as usize; + let max_edge = w.max(h).max(1); + let mut f = ((radius / DISC_CELLS).ceil() as usize).max(at_least).clamp(1, max_edge); + loop { + let shift = (origin.0 % f, origin.1 % f); + let (lw, lh) = (w.saturating_add(shift.0).div_ceil(f), h.saturating_add(shift.1).div_ceil(f)); + if lw.saturating_mul(lh) <= MAX_CELLS || f == max_edge { + return (f, shift, lw, lh); + } + f = f.saturating_add(1); + } +} + +/// The normalized disc blur of radius `rho` cells of weighted sums `cells` (`[r·w, g·w, b·w, w]`, +/// `lw × lh`): per row of the disc, a span read from that row's prefix sums, its two end cells +/// weighted by the fraction of them inside. +fn disc(cells: &[[f32; 4]], lw: usize, lh: usize, rho: f32) -> Rgb32f { + let stride = lw + 1; + let mut pre = vec![[0f64; 4]; stride * lh]; + crate::for_rows(&mut pre, stride, |y, p| { + let Some(row) = cells.get(y * lw..(y + 1) * lw) else { return }; + let mut acc = [0f64; 4]; + for (x, c) in row.iter().enumerate() { + for ch in 0..4 { + acc[ch] += c[ch] as f64; + } + if let Some(slot) = p.get_mut(x + 1) { + *slot = acc; + } + } + }); + // (a disc past the grid reads nothing more) + let rho = if rho.is_finite() { rho.clamp(0.0, lw.max(lh) as f32 + 1.0) } else { 0.0 }; + let n = rho.ceil() as isize; + let spans: Vec<(isize, isize, f64)> = (-n..=n) + .filter_map(|dy| { + let q = rho * rho - (dy * dy) as f32; + (q >= 0.0).then(|| { + let hw = q.sqrt(); + (dy, hw.floor() as isize, (hw - hw.floor()) as f64) + }) + }) + .collect(); + let mut out = Rgb32f::new(lw, lh); + let (lwi, lhi) = (lw as isize, lh as isize); + crate::for_rows(&mut out.data, lw, |y, row| { + for (x, px) in row.iter_mut().enumerate() { + let x = x as isize; + let mut acc = [0f64; 4]; + for &(dy, hw, fr) in &spans { + let yy = y as isize + dy; + if yy < 0 || yy >= lhi { + continue; + } + let base = yy as usize * stride; + let (a, b) = ((x - hw).max(0), (x + hw + 1).min(lwi)); + if a < b + && let (Some(pa), Some(pb)) = (pre.get(base + a as usize), pre.get(base + b as usize)) + { + for ch in 0..4 { + acc[ch] += pb[ch] - pa[ch]; + } + } + if fr > 0.0 { + for xx in [x - hw - 1, x + hw + 1] { + if xx >= 0 + && xx < lwi + && let Some(c) = cells.get(yy as usize * lw + xx as usize) + { + for ch in 0..4 { + acc[ch] += fr * c[ch] as f64; + } + } + } + } + } + *px = if acc[3] > 0.0 { std::array::from_fn(|c| (acc[c] / acc[3]) as f32) } else { [0.0; 3] }; + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_develop::{MaskComponent, MaskOp}; + use lightcraft_geom::Point; + + /// Settings with the lens blur switched on at `amount`. + fn on(amount: f64) -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.lens_blur.enabled = true; + s.lens_blur.amount = amount; + s + } + + /// A frame for a `w × h` source rendered at its own size. + fn frame(s: &DevelopSettings, w: usize, h: usize) -> Frame { + Frame::new(w, h, s, true) + } + + /// A radial mask around the centre (radius `r`, long-edge units). + fn centre_mask(id: u32, r: f64) -> Mask { + let mut m = Mask { id, ..Mask::default() }; + m.components.push(MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Radial { center: Point::new(0.5, 0.5), rx: r, ry: r, angle: 0.0, feather: 0.0, invert: false }, + }); + m + } + + /// A black image with one bright point at `(px, py)` and white stripes in the left third. + fn scene(w: usize, h: usize, px: usize, py: usize) -> Rgb32f { + let mut img = Rgb32f::from_fn(w, h, |x, _| if x < w / 3 && x % 4 < 2 { [0.6; 3] } else { [0.02; 3] }); + img.set(px, py, [4.0; 3]); + img + } + + /// On a 60 MP export, the strongest global and per-mask settings keep a fixed-size disc kernel + /// and every level's coarse grid within its cell budget, including an offset render window. + #[test] + fn large_image_blur_work_is_bounded() { + let (w, h) = (9500, 6316); + let long_edge = w.max(h) as f64; + let s = on(100.0); + let r = radius_px(&s, long_edge); + assert!((r - (MAX_RADIUS * long_edge) as f32).abs() < 1e-3); + + let mut mask = Mask::default(); + mask.adjust.lens_blur = 100.0; + assert!((mask_radius_px(&mask, long_edge) - r).abs() < 1e-3); + + let mut total_cells = 0usize; + for k in 1..=LEVELS { + let radius = r * k as f32 / LEVELS as f32; + let (f, _, lw, lh) = level_grid(w, h, radius, (17, 29)); + let cells = lw.saturating_mul(lh); + assert!(cells <= MAX_CELLS, "level {k}: {lw} × {lh} = {cells}"); + total_cells = total_cells.saturating_add(cells); + assert!(radius / f as f32 <= DISC_CELLS); + } + assert!(total_cells <= LEVELS * MAX_CELLS); + + let (_, _, lw, lh) = level_grid(w, h, r, (w - 1, h - 1)); + assert!(lw.saturating_mul(lh) <= MAX_CELLS, "offset window: {lw} × {lh}"); + } + + /// Switched off, at Amount 0 or with the section's eye closed, the photo is untouched. + #[test] + fn off_amount_zero_or_section_hidden_changes_nothing() { + let img = scene(64, 48, 50, 10); + for s in [DevelopSettings::default(), on(0.0), { + let mut s = on(80.0); + s.set_section_enabled("lensBlur", false); + s + }] { + let mut out = img.clone(); + apply(&mut out, &s, &frame(&s, 64, 48), 64.0, None); + assert_eq!(out, img); + assert_eq!(key(&s), ""); + } + } + + /// An out-of-focus point of light spreads into a disc of the expected radius and keeps its energy. + #[test] + fn out_of_focus_point_becomes_a_disc_and_keeps_its_energy() { + let (w, h) = (200, 200); + let mut s = on(100.0); + // in focus: a small disc in the middle; the point sits far outside it + s.masks.push(centre_mask(7, 0.05)); + s.lens_blur.focus_mask = Some(7); + let mut img = Rgb32f::filled(w, h, [0.0; 3]); + img.set(40, 40, [100.0; 3]); + let before: f32 = img.data.iter().map(|c| c[1]).sum(); + apply(&mut img, &s, &frame(&s, w, h), w as f64, None); + let r = radius_px(&s, w as f64); + assert!((r - 7.0).abs() < 1e-3, "{r}"); + // spread over a disc: inside it lit, well outside it dark, the centre no longer a spike + let at = |x: usize, y: usize| img.get(x, y)[1]; + assert!(at(40, 40) < 10.0, "{}", at(40, 40)); + for (x, y) in [(44, 40), (40, 36), (37, 43)] { + assert!(at(x, y) > 0.2, "({x}, {y}) {}", at(x, y)); + } + for (x, y) in [(52, 40), (40, 28), (50, 50)] { + assert!(at(x, y) < 0.05, "({x}, {y}) {}", at(x, y)); + } + let after: f32 = img.data.iter().map(|c| c[1]).sum(); + assert!((after - before).abs() / before < 0.15, "{before} → {after}"); + } + + /// What is in focus stays sharp, and its brightness doesn't bleed into the blurred background as a halo. + #[test] + fn the_focus_stays_sharp_and_does_not_bleed_into_the_background() { + let (w, h) = (160, 160); + let mut s = on(100.0); + s.masks.push(centre_mask(3, 0.2)); + s.lens_blur.focus_mask = Some(3); + // a bright subject filling the focus (a disc of 30 px; the focus is 32 px), grey stripes + // around it + let mut img = Rgb32f::from_fn(w, h, |x, y| { + let d = ((x as f32 + 0.5 - 80.0).powi(2) + (y as f32 + 0.5 - 80.0).powi(2)).sqrt(); + if d < 30.0 { + [2.0; 3] + } else if x % 4 < 2 { + [0.3; 3] + } else { + [0.1; 3] + } + }); + let src = img.clone(); + apply(&mut img, &s, &frame(&s, w, h), w as f64, None); + // the subject is untouched + assert_eq!(img.get(80, 80), src.get(80, 80)); + assert_eq!(img.get(60, 70), src.get(60, 70)); + // the background's stripes are smoothed out… + let row: Vec = (4..40).map(|x| img.get(x, 10)[1]).collect(); + let (lo, hi) = row.iter().fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(*v), b.max(*v))); + assert!(hi - lo < 0.04, "{lo}..{hi}"); + // …to their mean, also 5 px from the bright subject (within the blur's reach): no halo + let near = img.get(45, 80)[1]; + assert!((near - 0.2).abs() < 0.04, "{near}"); + } + + /// Boost brightens the blurred highlights only: a mid-grey background stays as it is. + #[test] + fn boost_brightens_bokeh_only() { + let (w, h) = (120, 120); + let mut s = on(60.0); + s.masks.push(centre_mask(1, 0.05)); + s.lens_blur.focus_mask = Some(1); + let mut img = Rgb32f::filled(w, h, [0.1; 3]); + img.set(20, 20, [8.0; 3]); + let (mut plain, mut boosted) = (img.clone(), img.clone()); + apply(&mut plain, &s, &frame(&s, w, h), w as f64, None); + s.lens_blur.boost = 100.0; + apply(&mut boosted, &s, &frame(&s, w, h), w as f64, None); + assert!(boosted.get(22, 20)[0] > plain.get(22, 20)[0] * 2.0, "{} vs {}", boosted.get(22, 20)[0], plain.get(22, 20)[0]); + // a mid-grey background stays as it is + assert!((boosted.get(100, 100)[0] - plain.get(100, 100)[0]).abs() < 1e-4); + } + + /// A small preview and a full-size export blur alike (the radius is relative to the long edge). + #[test] + fn previews_and_exports_blur_alike() { + // the same photo at two sizes: the blur scales with the long edge + let s = on(100.0); + let big = scene(240, 180, 200, 30); + let small = Rgb32f::from_fn(120, 90, |x, y| { + let p = |dx, dy| big.get(2 * x + dx, 2 * y + dy); + let q = [p(0, 0), p(1, 0), p(0, 1), p(1, 1)]; + std::array::from_fn(|c| q.iter().map(|v| v[c]).sum::() / 4.0) + }); + let (mut a, mut b) = (big.clone(), small.clone()); + apply(&mut a, &s, &frame(&s, 240, 180), 240.0, None); + apply(&mut b, &s, &frame(&s, 120, 90), 120.0, None); + let mut diff = 0.0; + for y in 0..90 { + for x in 0..120 { + let d = a.get(2 * x, 2 * y)[1] - b.get(x, y)[1]; + diff += d.abs(); + } + } + let mean = diff / (120.0 * 90.0); + assert!(mean < 0.02, "{mean}"); + } + + /// Brush Refinement: a Focus stroke keeps what it covers sharp, a Blur stroke blurs what it + /// covers even inside the focus. + #[test] + fn focus_and_blur_brushes_refine_the_focus() { + let (w, h) = (160, 120); + let img = Rgb32f::from_fn(w, h, |x, _| if x % 4 < 2 { [0.3; 3] } else { [0.1; 3] }); + let mut s = on(100.0); + // in focus: the middle + s.masks.push(centre_mask(5, 0.15)); + s.lens_blur.focus_mask = Some(5); + let dab = |x: f64, y: f64| BrushStroke { points: vec![Point::new(x, y)], size: 0.08, feather: 0.0, ..BrushStroke::default() }; + // Focus brush on the top-left corner (out of focus otherwise), Blur brush on the centre + s.lens_blur.focus_strokes.push(dab(0.1, 0.12)); + s.lens_blur.blur_strokes.push(dab(0.5, 0.5)); + let mut out = img.clone(); + apply(&mut out, &s, &frame(&s, w, h), w as f64, None); + let stripes = |o: &Rgb32f, cx: usize, cy: usize| { + let v: Vec = (cx - 4..cx + 4).map(|x| o.get(x, cy)[1]).collect(); + v.iter().fold(f32::MIN, |a, b| a.max(*b)) - v.iter().fold(f32::MAX, |a, b| a.min(*b)) + }; + // the corner the Focus brush painted keeps its stripes; one it didn't is smooth + assert!(stripes(&out, 16, 14) > 0.15, "{}", stripes(&out, 16, 14)); + assert!(stripes(&out, 140, 100) < 0.05, "{}", stripes(&out, 140, 100)); + // the centre is in focus, but the Blur brush wins + assert!(stripes(&out, 80, 60) < 0.05, "{}", stripes(&out, 80, 60)); + // without the strokes, the centre stays sharp + let mut plain = s.clone(); + plain.lens_blur.focus_strokes.clear(); + plain.lens_blur.blur_strokes.clear(); + let mut out = img.clone(); + apply(&mut out, &plain, &frame(&plain, w, h), w as f64, None); + assert!(stripes(&out, 80, 60) > 0.15); + assert_ne!(key(&plain), key(&s)); + } + + /// Without a focus mask, a fixed shape keeps a circle, a wide or a tall oval sharp in the + /// middle: a wide one reaches farther sideways than up, a tall one the other way round. + #[test] + fn fixed_focus_shapes() { + use lightcraft_develop::FocusShape; + let (w, h) = (300, 300); + let s0 = on(100.0); + let fr = frame(&s0, w, h); + let img = Rgb32f::filled(w, h, [0.2; 3]); + let alpha = |shape: FocusShape| { + let mut s = s0.clone(); + s.lens_blur.shape = shape; + focus(&img, &s, &fr, None) + }; + // sideways and upwards from the centre, 0.2 of the long edge away + let side = |a: &Plane| a.get(150 + 60, 150); + let up = |a: &Plane| a.get(150, 150 - 60); + let (round, wide, tall) = (alpha(FocusShape::Round), alpha(FocusShape::Wide), alpha(FocusShape::Tall)); + for a in [&round, &wide, &tall] { + assert!(a.get(150, 150) > 0.99, "the middle is in focus"); + assert!(a.get(5, 5) < 0.01, "a corner isn't"); + } + assert!((side(&round) - up(&round)).abs() < 0.05); + assert!(side(&wide) > 0.9 && up(&wide) < 0.1, "{} {}", side(&wide), up(&wide)); + assert!(up(&tall) > 0.9 && side(&tall) < 0.1, "{} {}", side(&tall), up(&tall)); + // moved and resized: the area follows; hostile values are tamed + let mut s = s0.clone(); + s.lens_blur.shape = FocusShape::Round; + s.lens_blur.area = Some(lightcraft_develop::FocusArea { center: Point::new(0.2, 0.2), rx: 0.1, ry: 0.1 }); + let a = focus(&img, &s, &fr, None); + assert!(a.get(60, 60) > 0.99 && a.get(150, 150) < 0.01, "moved to the top left"); + s.lens_blur.area = Some(lightcraft_develop::FocusArea { center: Point::new(f64::NAN, 9e9), rx: f64::INFINITY, ry: -3.0 }); + let a = focus(&img, &s, &fr, None); + assert!(a.data.iter().all(|v| v.is_finite())); + // an unknown shape in the JSON reads as Auto (never an error) + let b: lightcraft_develop::LensBlur = serde_json::from_value(serde_json::json!({"shape": "hexagon"})).unwrap(); + assert_eq!(b.shape, FocusShape::Auto); + } + + /// With a Transition the blur grows with the distance from the focus, as a lens renders what + /// is near the subject: soft right beside it, full far away. At 0 it is a sharp cut. + #[test] + fn the_blur_grows_away_from_the_focus() { + let (w, h) = (400, 200); + // wide stripes: how much of them a blur leaves says how large it is + let img = Rgb32f::from_fn(w, h, |x, _| if (x / 12) % 2 == 0 { [0.3; 3] } else { [0.1; 3] }); + let mut s = on(100.0); + // in focus: a disc left of centre + let mut m = centre_mask(2, 0.08); + if let MaskShape::Radial { center, .. } = &mut m.components[0].shape { + *center = Point::new(0.2, 0.5); + } + s.masks.push(m); + s.lens_blur.focus_mask = Some(2); + // how much detail is left (stripe contrast) around x, on the middle row + let detail = |o: &Rgb32f, x: usize| { + let v: Vec = (x - 12..x + 12).map(|x| o.get(x, 100)[1]).collect(); + v.iter().fold(f32::MIN, |a, b| a.max(*b)) - v.iter().fold(f32::MAX, |a, b| a.min(*b)) + }; + let render = |t: f64| { + let mut s = s.clone(); + s.lens_blur.transition = t; + let mut o = img.clone(); + apply(&mut o, &s, &frame(&s, w, h), w as f64, None); + o + }; + let (soft, cut) = (render(100.0), render(0.0)); + // the focus disc ends at x ≈ 112 (the ramp is 60 px long): just past it, then farther + let (near, mid, far) = (detail(&soft, 130), detail(&soft, 150), detail(&soft, 330)); + assert!(near > mid + 0.02 && mid > far + 0.02, "{near} > {mid} > {far}"); + // a sharp cut blurs right beside the focus as much as far away + let cut_near = detail(&cut, 130); + assert!((cut_near - detail(&cut, 330)).abs() < 0.02 && near > cut_near + 0.05, "{cut_near} vs {near}"); + // the focus itself stays sharp either way + assert!(detail(&soft, 70) > 0.19 && detail(&cut, 70) > 0.19); + } + + /// A focus mask whose AI selection isn't computed yet (Find Subject still running) leaves the + /// subject estimate in focus: the photo is never blurred all over meanwhile. + #[test] + fn a_pending_ai_focus_mask_falls_back_to_the_subject_estimate() { + let (w, h) = (160, 120); + let img = Rgb32f::from_fn(w, h, |x, _| if x % 4 < 2 { [0.3; 3] } else { [0.1; 3] }); + let mut s = on(100.0); + let mut m = Mask { id: 9, ..Mask::default() }; + let pending = MaskShape::Object { hint: vec![], exclude: vec![], seg: None, detail: vec![], edge: 0.0 }; + m.components.push(MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: pending }); + assert!(awaiting_ai(&m)); + s.masks.push(m); + s.lens_blur.focus_mask = Some(9); + let mut subject = s.clone(); + subject.lens_blur.focus_mask = None; + let (mut a, mut b) = (img.clone(), img.clone()); + apply(&mut a, &s, &frame(&s, w, h), w as f64, None); + apply(&mut b, &subject, &frame(&subject, w, h), w as f64, None); + assert_eq!(a, b, "the same as no focus mask"); + // the middle (the estimate's subject) keeps its stripes + let v: Vec = (76..84).map(|x| a.get(x, 60)[1]).collect(); + assert!(v.iter().fold(f32::MIN, |p, q| p.max(*q)) - v.iter().fold(f32::MAX, |p, q| p.min(*q)) > 0.1, "{v:?}"); + } + + /// A mask with Lens Blur blurs what it selects and nothing else; hidden, it blurs nothing. + #[test] + fn a_mask_blurs_only_what_it_selects() { + let (w, h) = (160, 120); + // vertical stripes everywhere + let img = Rgb32f::from_fn(w, h, |x, _| if x % 4 < 2 { [0.3; 3] } else { [0.1; 3] }); + let mut s = DevelopSettings::default(); + // the left half (a linear gradient with a short ramp in the middle) + let mut m = Mask { id: 2, ..Mask::default() }; + m.components.push(MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }, + }); + m.adjust.lens_blur = 100.0; + s.masks.push(m); + assert!(renders(&s) && !active(&s) && !key(&s).is_empty()); + let mut out = img.clone(); + apply(&mut out, &s, &frame(&s, w, h), w as f64, None); + // left: the stripes are smoothed to their mean + let left: Vec = (8..50).map(|x| out.get(x, 60)[1]).collect(); + let (lo, hi) = left.iter().fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(*v), b.max(*v))); + assert!(hi - lo < 0.05 && (lo + hi) / 2.0 > 0.15 && (lo + hi) / 2.0 < 0.25, "{lo}..{hi}"); + // right: untouched + for x in 100..150 { + assert_eq!(out.get(x, 60), img.get(x, 60), "x {x}"); + } + // the window grows by the mask's blur + let win = crate::PixelWindow { x: 60, y: 40, w: 20, h: 20 }; + assert!(window_for_reads(&s, w, h, w as f64, win).w > 20); + // hidden or at 0: nothing + for change in [|m: &mut Mask| m.visible = false, |m: &mut Mask| m.adjust.lens_blur = 0.0, |m: &mut Mask| m.adjust.lens_blur_off = true] { + let mut t = s.clone(); + change(&mut t.masks[0]); + let mut out = img.clone(); + apply(&mut out, &t, &frame(&t, w, h), w as f64, None); + assert_eq!(out, img); + assert!(!renders(&t) && key(&t).is_empty()); + } + } + + /// A mask's Lens Blur block can blur what the mask leaves out, and its Transition makes that + /// blur grow away from what stays sharp. + #[test] + fn a_mask_blurs_its_outside_gradually() { + let (w, h) = (320, 120); + let img = Rgb32f::from_fn(w, h, |x, _| if x % 4 < 2 { [0.3; 3] } else { [0.1; 3] }); + let mut s = DevelopSettings::default(); + // the left third selected + let mut m = Mask { id: 2, ..Mask::default() }; + m.components.push(MaskComponent { + name: None, + op: MaskOp::Add, + invert: false, + shape: MaskShape::Linear { start: Point::new(0.30, 0.5), end: Point::new(0.34, 0.5) }, + }); + m.adjust.lens_blur = 100.0; + m.adjust.lens_blur_outside = true; + s.masks.push(m); + // how much of the stripes is left around column x + let spread = |out: &Rgb32f, x: usize| { + let v: Vec = (x..x + 8).map(|x| out.get(x, 60)[1]).collect(); + v.iter().fold(f32::MIN, |a, b| a.max(*b)) - v.iter().fold(f32::MAX, |a, b| a.min(*b)) + }; + let mut sharp_cut = img.clone(); + apply(&mut sharp_cut, &s, &frame(&s, w, h), w as f64, None); + // the selection stays sharp, the rest is blurred + for x in 8..80 { + assert_eq!(sharp_cut.get(x, 60), img.get(x, 60), "x {x}"); + } + assert!(spread(&sharp_cut, 150) < 0.05 && spread(&sharp_cut, 290) < 0.05); + // with a Transition, near the selection keeps more of the stripes than far from it + s.masks[0].adjust.lens_blur_transition = 100.0; + s.masks[0].adjust.lens_blur_boost = 50.0; + let mut gradual = img.clone(); + apply(&mut gradual, &s, &frame(&s, w, h), w as f64, None); + assert!(spread(&gradual, 112) > spread(&sharp_cut, 112) + 0.02, "{} vs {}", spread(&gradual, 112), spread(&sharp_cut, 112)); + assert!(spread(&gradual, 112) > spread(&gradual, 290), "{} vs {}", spread(&gradual, 112), spread(&gradual, 290)); + // the window reads farther with the transition + let win = crate::PixelWindow { x: 200, y: 40, w: 20, h: 20 }; + let mut no_t = s.clone(); + no_t.masks[0].adjust.lens_blur_transition = 0.0; + assert!(window_for_reads(&s, w, h, w as f64, win).w > window_for_reads(&no_t, w, h, w as f64, win).w); + // at Amount 0 the outside isn't blurred either + s.masks[0].adjust.amount = 0.0; + let mut out = img.clone(); + apply(&mut out, &s, &frame(&s, w, h), w as f64, None); + assert_eq!(out, img); + // hostile values + s.masks[0].adjust.amount = f64::NAN; + s.masks[0].adjust.lens_blur_transition = f64::INFINITY; + s.masks[0].adjust.lens_blur_boost = f64::NAN; + let mut out = img.clone(); + apply(&mut out, &s, &frame(&s, w, h), w as f64, None); + assert!(out.data.iter().all(|p| p.iter().all(|v| v.is_finite()))); + } + + /// The subject point lands on a person matte when the photo carries one, and is always inside + /// the photo; an empty source has none. + #[test] + fn subject_point_finds_the_person_matte() { + use crate::masks::{MatteKind, Mattes}; + let s = DevelopSettings::default(); + let src = Rgb32f::filled(300, 200, [0.2; 3]); + // a person in the top-right corner, far from the centre the heuristic favours + let mut person = lightcraft_raster::Image::::new(300, 200); + for y in 10..60 { + for x in 220..290 { + person.set(x, y, 255); + } + } + let mut mattes = Mattes::default(); + mattes.push(MatteKind::Person, person); + let p = subject_point(&src, &s, Some(&mattes)).unwrap(); + assert!(p.x > 0.72 && p.x < 0.97 && p.y > 0.04 && p.y < 0.3, "{p:?}"); + // without mattes: somewhere in the photo (the heuristic's centre-weighted peak) + let p = subject_point(&src, &s, None).unwrap(); + assert!((0.0..=1.0).contains(&p.x) && (0.0..=1.0).contains(&p.y), "{p:?}"); + assert!(subject_point(&Rgb32f::new(0, 0), &s, None).is_none()); + } + + /// Hostile settings and images: no panic, no NaN. + #[test] + fn hostile_input_never_panics() { + for amount in [f64::NAN, f64::INFINITY, -5.0, 1e9] { + let mut s = on(amount); + s.lens_blur.boost = f64::NAN; + s.lens_blur.focus_mask = Some(99); + for (w, h) in [(1, 1), (1, 7), (9, 1), (33, 5)] { + let mut img = Rgb32f::filled(w, h, [0.5, f32::NAN, f32::INFINITY]); + apply(&mut img, &s, &frame(&s, w, h), 1e6, None); + let mut img = Rgb32f::filled(w, h, [0.5; 3]); + apply(&mut img, &s, &frame(&s, w, h), w.max(h) as f64, None); + assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{amount} {w}×{h}"); + } + } + // a mismatched buffer is left alone + let s = on(50.0); + let mut img = Rgb32f { width: 10, height: 10, data: vec![[0.5; 3]; 7] }; + apply(&mut img, &s, &frame(&s, 10, 10), 10.0, None); + assert_eq!(img.data.len(), 7); + } +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 34bd402336..ef2d2359ca 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -32,6 +32,7 @@ pub mod curve; pub mod dust; pub mod finish; pub mod geometry; +pub mod lens_blur; pub mod light_v3; // generated (`light_v3_params.rs`), kept in its own compact layout #[rustfmt::skip] @@ -444,6 +445,8 @@ pub struct Plan<'a> { pub keep: Option, /// The source's segmentation mattes ([`SourceInfo::mattes`]). pub mattes: Option>, + /// The whole photo's subject estimate a lens blur focuses on ([`lens_blur::subject_estimate`]). + pub lens_subject: Option>, /// The whole frame and its output size (a windowed render's [`Self::frame`] is part of it). pub full: (geometry::Frame, usize, usize), } @@ -487,8 +490,10 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R let (pw, ph) = (((full_w as f64 * k).round() as usize).max(1), ((full_h as f64 * k).round() as usize).max(1)); fixed_air = Some(local::frame_airlight(src, info, s, &frame, pw, ph)); } - // (spots grow the rendered window of 8-bit renders; a grown window is cut back to the request) - let mut work = if req.depth == OutputDepth::U8 { spots::window_for_reads(s, &frame, full_w, full_h, px_per_long, win) } else { win }; + // (spots grow the rendered window of 8-bit renders, the lens blur that of every + // render; a grown window is cut back to the request) + let work = if req.depth == OutputDepth::U8 { spots::window_for_reads(s, &frame, full_w, full_h, px_per_long, win) } else { win }; + let mut work = lens_blur::window_for_reads(s, full_w, full_h, px_per_long, work); if local::joint_colour_nr(s) && let Some(params) = local::nrc_params(s, detail_scale) { @@ -522,6 +527,8 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R // and the local tone mapping local_tone::enabled(info, s), src_long, + // and the lens blur (after noise reduction, but a draft must not reuse planes without it) + lens_blur::key(s), )); let lin_key = hash_of(( pre_nr_key, @@ -530,6 +537,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R // the process picks the colour filter (Update to Current Process, undo) local::joint_colour_nr(s), )); + let lens_subject = lens_blur::subject_estimate(src, s, info.mattes.as_deref()).map(Arc::new); let noise_key = hash_of((pre_nr_key, [d.nr_luminance, d.nr_detail].map(f64::to_bits), detail_scale.to_bits())); Plan { settings, @@ -548,6 +556,7 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R fixed_air, keep, mattes: info.mattes.clone(), + lens_subject, full, } } @@ -585,6 +594,7 @@ pub fn colour_noise(src: &Rgb32f, info: &SourceInfo, p: &Plan<'_>) -> Option, info: &SourceInfo, s: &DevelopSettings, req: &Rende colour_noise = self::colour_noise(src_img, info, &plan).map(|n| (plan.noise_key, n)); } local::denoise(&mut img, s, detail_scale, colour_noise.as_ref().map(|c| &c.1)); + lens_blur::apply_with(&mut img, s, frame, px_per_long, plan.mattes.as_deref(), plan.lens_subject.as_deref()); Arc::new(img) } }; diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index b40e3dcd97..dfbf00326a 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -93,6 +93,12 @@ impl Mattes { self.mattes.iter().filter(|(k, _)| kinds.contains(k)).map(|(_, m)| m).collect() } + /// Whether a mask of the photo's Subject comes from these mattes (a person, or a person's + /// parts) rather than from the saliency heuristic. + pub fn selects_subject(&self) -> bool { + !self.for_shape(&MaskShape::Subject).is_empty() + } + /// The mattes a shape uses instead of its heuristic (empty: none fits). fn for_shape(&self, shape: &MaskShape) -> Vec<&Image> { use MatteKind::*; diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index b514c25503..c3fa176eb6 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -346,6 +346,30 @@ pub struct DeepImage { } impl DeepImage { + /// The `w × h` part of the image at (`x`, `y`), clamped to the image (at least 1 × 1 when the + /// image has pixels). Samples are RGB, three per pixel, row-major. + pub fn crop(&self, x: usize, y: usize, w: usize, h: usize) -> DeepImage { + let x = x.min(self.width.saturating_sub(1)); + let y = y.min(self.height.saturating_sub(1)); + let w = w.min(self.width.saturating_sub(x)).max(usize::from(self.width > 0)); + let h = h.min(self.height.saturating_sub(y)).max(usize::from(self.height > 0)); + fn cut(v: &[T], width: usize, x: usize, y: usize, w: usize, h: usize) -> Vec { + let mut out = Vec::with_capacity(w.saturating_mul(h).saturating_mul(3)); + for row in y..y + h { + let start = row.saturating_mul(width).saturating_add(x).saturating_mul(3); + if let Some(r) = v.get(start..start.saturating_add(w * 3)) { + out.extend_from_slice(r); + } + } + out + } + let samples = match &self.samples { + DeepSamples::U16(v) => DeepSamples::U16(cut(v, self.width, x, y, w, h)), + DeepSamples::F32(v) => DeepSamples::F32(cut(v, self.width, x, y, w, h)), + }; + DeepImage { width: w, height: h, space: self.space, samples } + } + /// The same image reduced to 8 bits, display-encoded with the space's curve. pub fn to_rgba8(&self) -> lightcraft_raster::Rgba8 { let mut out = lightcraft_raster::Rgba8::new(self.width, self.height); @@ -364,27 +388,6 @@ impl DeepImage { } out } - - /// Crop to `[x0, x0+w) × [y0, y0+h)` (clamped to the image). - pub fn crop(&self, x0: usize, y0: usize, w: usize, h: usize) -> Self { - let x0 = x0.min(self.width); - let y0 = y0.min(self.height); - let w = w.min(self.width - x0); - let h = h.min(self.height - y0); - fn rows(v: &[T], width: usize, x0: usize, y0: usize, w: usize, h: usize) -> Vec { - let mut out = Vec::with_capacity(3 * w * h); - for y in y0..y0 + h { - let at = 3 * (y * width + x0); - out.extend_from_slice(v.get(at..at + 3 * w).unwrap_or_default()); - } - out - } - let samples = match &self.samples { - DeepSamples::U16(v) => DeepSamples::U16(rows(v, self.width, x0, y0, w, h)), - DeepSamples::F32(v) => DeepSamples::F32(rows(v, self.width, x0, y0, w, h)), - }; - Self { width: w, height: h, space: self.space, samples } - } } trait IntoRgba { @@ -401,6 +404,23 @@ impl IntoRgba for [u8; 3] { mod tests { use super::*; + /// A crop keeps the right pixels and never reads past the image, whatever the request. + #[test] + fn deep_crop_keeps_the_window_and_clamps() { + // 4 × 3, pixel (x, y) holds x + 10 y in every channel + let v: Vec = (0..3).flat_map(|y| (0..4).flat_map(move |x| [x + 10 * y; 3])).collect(); + let d = DeepImage { width: 4, height: 3, space: OutputSpace::Srgb, samples: DeepSamples::U16(v) }; + let c = d.crop(1, 1, 2, 2); + assert_eq!((c.width, c.height), (2, 2)); + assert_eq!(c.samples, DeepSamples::U16(vec![11, 11, 11, 12, 12, 12, 21, 21, 21, 22, 22, 22])); + for (x, y, w, h) in [(9, 9, 5, 5), (0, 0, usize::MAX, usize::MAX), (3, 2, 0, 0)] { + let c = d.crop(x, y, w, h); + let DeepSamples::U16(s) = &c.samples else { panic!("a U16 image crops to U16") }; + assert!(c.width >= 1 && c.height >= 1 && c.width <= 4 && c.height <= 3); + assert_eq!(s.len(), c.width * c.height * 3, "{x} {y} {w} {h}"); + } + } + #[test] fn trcs_invert() { for o in OutputSpace::ALL { diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index e5fb6ea801..7fa71a5f25 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -6,7 +6,7 @@ use lightcraft_develop::{DevelopSettings, controls}; use lightcraft_geom::{Orientation, Rect}; use lightcraft_raster::{Rgb32f, Rgba8}; -use crate::{PixelWindow, RenderRequest, SourceInfo, render}; +use crate::{OutputDepth, PixelWindow, RenderRequest, SourceInfo, render}; const W: usize = 960; const H: usize = 640; @@ -422,6 +422,73 @@ fn auto_mask_strokes_match_the_whole_render() { check_noisy("auto mask", &s, PixelWindow { x: 400, y: 300, w: 300, h: 250 }, 2, 0.1); } +// Given a lens blur, a window (zoomed view) blurs right up to its edges as the whole render does: +// it is rendered with the blur's reach around it +#[test] +fn lens_blur_matches_the_whole_render_up_to_the_window_edges() { + lens_blur_window_matches(OutputDepth::U8); + lens_blur_window_matches(OutputDepth::U16); +} + +/// A high-bit-depth window grows for the lens blur too, and is cut back to the request. +fn lens_blur_window_matches(depth: OutputDepth) { + use lightcraft_develop::{Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let radial = MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.15, ry: 0.2, angle: 0.0, feather: 30.0, invert: false }; + let mut s = DevelopSettings { + masks: vec![Mask { + id: 4, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: radial }], + ..Default::default() + }], + ..Default::default() + }; + s.lens_blur.enabled = true; + s.lens_blur.amount = 80.0; + s.lens_blur.boost = 40.0; + s.lens_blur.focus_mask = Some(4); + let deep = |win: Option| { + let r = render(&scene(), &SourceInfo::default(), &s, &RenderRequest { window: win, depth, ..RenderRequest::fit(W, H) }); + if let Some(d) = &r.deep { + assert_eq!((d.width, d.height), (r.image.width, r.image.height)); + } + r.image + }; + let (f, w) = (deep(None), deep(Some(MID))); + assert_eq!((w.width, w.height), (MID.w, MID.h)); + let (max, mean) = compare_inset(&f, &w, MID, 0); + assert!(max <= 6 && mean <= 0.4, "max {max}, mean {mean:.3}"); + // and the blur shows: the whole render differs from the one without it + let mut off = s.clone(); + off.lens_blur.enabled = false; + let (_, mean) = compare_inset(&full(&off), &f, PixelWindow { x: 0, y: 0, w: f.width, h: f.height }, 0); + assert!(mean > 1.0, "{mean:.3}"); +} + +/// With the automatic subject focus (no focus mask, no fixed shape, no person matte) a window +/// focuses where the whole render does: the estimate comes from the whole photo, not from the +/// pixels the window holds. +#[test] +fn lens_blur_on_the_estimated_subject_matches_the_whole_render() { + let mut s = DevelopSettings::default(); + s.lens_blur.enabled = true; + s.lens_blur.amount = 80.0; + // a sharp cut: the window then grows only by the blur's radius, less than the estimate reads + s.lens_blur.transition = 0.0; + for depth in [OutputDepth::U8, OutputDepth::U16] { + let r = |win: Option| { + render(&scene(), &SourceInfo::default(), &s, &RenderRequest { window: win, depth, ..RenderRequest::fit(W, H) }).image + }; + // a window at the middle and one at an edge, where the estimate's neighbourhood is cut off + for win in [MID, PixelWindow { x: 0, y: 0, w: 300, h: 220 }] { + let (f, w) = (r(None), r(Some(win))); + assert_eq!((w.width, w.height), (win.w, win.h)); + let (max, mean) = compare_inset(&f, &w, win, 0); + assert!(max <= 6 && mean <= 0.4, "{depth:?} {win:?}: max {max}, mean {mean:.3}"); + } + } +} + // Given the joint colour NR, a window is filtered with the whole frame's noise model: a quiet part of // the frame does not make a window of its noisy part (or the other way round) smooth differently #[test] diff --git a/crates/segment/src/detector.rs b/crates/segment/src/detector.rs index c302aafe76..3099af44af 100644 --- a/crates/segment/src/detector.rs +++ b/crates/segment/src/detector.rs @@ -333,10 +333,117 @@ pub fn merge(out: &Output, threshold: f32) -> Option { merged.map(|logits| Prediction { logits, score: best }) } +/// Share of the largest candidate's area another needs to count among the photo's main subjects +/// (people in front, not the crowd behind them). +pub const PROMINENT: f32 = 0.25; + +/// Which candidates are the photo's subject, given each one's area (cells it selects) and, when the +/// camera recorded where it focused, whether it covers that point. The focus decides: the +/// candidates it falls on, with the others about as large either way (a group at the same +/// distance); a focus on none of them means the subject is something else (empty: the caller +/// selects what is at the point). +/// Without a focus: the largest, and the others about as large. +pub fn pick_prominent(areas: &[usize], on_focus: Option<&[bool]>) -> Vec { + let largest = |ks: &mut dyn Iterator| ks.filter_map(|k| areas.get(k).copied()).max().unwrap_or(0); + let near = |a: usize, of: usize| of > 0 && a as f32 >= PROMINENT * of as f32; + match on_focus { + Some(hit) => { + let focused: Vec = (0..areas.len()).filter(|k| hit.get(*k).copied().unwrap_or(false) && areas[*k] > 0).collect(); + if focused.is_empty() { + return Vec::new(); + } + // about as large as what is in focus, either way: much larger is nearer the camera + // than the focus, so out of focus too + let of = largest(&mut focused.iter().copied()); + let alike = |a: usize| near(a, of) && (a as f32) * PROMINENT <= of as f32; + (0..areas.len()).filter(|k| focused.contains(k) || alike(areas[*k])).collect() + } + None => { + let of = largest(&mut (0..areas.len())); + (0..areas.len()).filter(|k| near(areas[*k], of)).collect() + } + } +} + +/// The instances of several detector outputs (one per concept: "person", "animal") above +/// `threshold` that [`pick_prominent`] keeps, each with its share of its concept's semantic mask, +/// merged into one mask. `focus`: where the camera focused (normalized). `None` when nothing is kept. +pub fn subject(outs: &[Output], threshold: f32, focus: Option<(f32, f32)>) -> Option { + let s = |x: f32| 1.0 / (1.0 + (-x).exp()); + // (mask, score, concept) + let mut cands: Vec<(Vec, f32, usize)> = Vec::new(); + for (c, out) in outs.iter().enumerate() { + let presence = s(out.presence); + for (i, l) in out.logits.iter().enumerate() { + let p = s(*l) * presence; + if p > threshold + && let Some(m) = out.masks.i(i).ok().and_then(|t| to_vec(&t).ok()) + { + cands.push((m, p, c)); + } + } + } + let n = cands.first()?.0.len(); + let side = (n as f64).sqrt() as usize; + if side == 0 || side * side != n || cands.iter().any(|c| c.0.len() != n) { + return None; + } + let areas: Vec = cands.iter().map(|c| c.0.iter().filter(|l| **l > 0.0).count()).collect(); + let cell = focus.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| { + let at = |v: f32| ((v.clamp(0.0, 1.0) * side as f32) as usize).min(side - 1); + at(y) * side + at(x) + }); + let hits: Option> = cell.map(|i| cands.iter().map(|c| c.0.get(i).is_some_and(|l| *l > 0.0)).collect()); + let keep = pick_prominent(&areas, hits.as_deref()); + if keep.is_empty() { + return None; + } + let best = keep.iter().filter_map(|k| cands.get(*k)).map(|c| c.1).fold(0f32, f32::max); + let mut merged: Option> = None; + for (c, out) in outs.iter().enumerate() { + let mut masks: Vec> = keep.iter().filter_map(|k| cands.get(*k)).filter(|x| x.2 == c).map(|x| x.0.clone()).collect(); + if masks.is_empty() { + continue; + } + share(&mut masks, &out.semantic); + for m in masks { + merged = Some(match merged { + Some(acc) => acc.iter().zip(&m).map(|(a, b)| a.max(*b)).collect(), + None => m, + }); + } + } + merged.map(|logits| Prediction { logits, score: best }) +} + #[cfg(test)] mod tests { use super::*; + /// A group in front is kept, the crowd behind isn't; the camera's focus decides: on a person, + /// that person and the others as large; on nobody, nothing (the object there is the subject). + #[test] + fn prominent_subjects() { + // four people in front, two small ones far behind + let areas = [900, 1000, 800, 950, 60, 40]; + assert_eq!(pick_prominent(&areas, None), vec![0, 1, 2, 3]); + // focused on the first: the group + let hit = [true, false, false, false, false, false]; + assert_eq!(pick_prominent(&areas, Some(&hit)), vec![0, 1, 2, 3]); + // focused on someone small behind: that one and whoever is about as large, not the + // (much larger, so nearer and out of focus) group in front + let hit = [false, false, false, false, true, false]; + assert_eq!(pick_prominent(&areas, Some(&hit)), vec![4, 5]); + // a product in front of people: the focus is on none of them + assert!(pick_prominent(&areas, Some(&[false; 6])).is_empty()); + // nobody at all, or empty masks + assert!(pick_prominent(&[], None).is_empty()); + assert!(pick_prominent(&[0, 0], None).is_empty()); + assert!(pick_prominent(&[0, 5], Some(&[true, false])).is_empty()); + // a mismatched focus list is read as "not on it" + assert_eq!(pick_prominent(&[10, 9], Some(&[true])), vec![0, 1]); + } + /// Two people side by side on a row of cells, each only weakly sure of themself and both fading where /// they touch; the semantic mask is sure of both and runs across. Each takes the semantic logits where /// it is the strongest instance, a third region nobody is near keeps its own. diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index fba28b00e9..98a47d9888 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -81,6 +81,10 @@ pub const MAX_CLICKS: usize = 64; #[cfg(not(target_arch = "wasm32"))] const _: () = assert!(MASK_SIDE == tracker::LOW_RES && INPUT == vit::IMAGE); +/// What a photo's subject usually is, as concept prompts ([`Sam3::segment_subject`]); anything else +/// (an object, a landscape's feature) is selected at the focus point instead. +pub const SUBJECT_CONCEPTS: &[&str] = &["person", "animal"]; + /// The files of a model directory. pub const WEIGHTS_FILE: &str = "model.safetensors"; @@ -247,6 +251,15 @@ impl Sam3 { Ok(detector::merge(&out, threshold)) } + /// The photo's subject among its people and animals ([`detector::subject`]): the ones the + /// camera's focus (`focus`, normalized; `None` when the file doesn't record it) falls on, or + /// without it the most prominent, with whoever is about as large. `None` when the subject is + /// neither (a focus on something else, or nobody in the photo): select what is at the point. + pub fn segment_subject(&mut self, enc: &mut Encoded, focus: Option<(f32, f32)>, threshold: f32) -> Result> { + let outs = SUBJECT_CONCEPTS.iter().map(|c| self.detect(enc, c)).collect::>>()?; + Ok(detector::subject(&outs, threshold, focus)) + } + /// Raw detector outputs for `text` (tests and tools). pub fn detect(&mut self, enc: &mut Encoded, text: &str) -> Result { let (det, tok) = self.detector_parts()?; diff --git a/crates/segment/src/remote.rs b/crates/segment/src/remote.rs index 10f7b2931a..4f23d10bc7 100644 --- a/crates/segment/src/remote.rs +++ b/crates/segment/src/remote.rs @@ -17,6 +17,9 @@ pub enum Operation { Prepare, Clicks(Vec<[f32; 3]>), Text(String), + /// The photo's subject among its people and animals ([`Sam3::segment_subject`]), from where + /// the camera focused (normalized `[x, y]`) when the file records it. + Subject(Option<[f32; 2]>), } #[derive(Serialize, Deserialize)] @@ -36,7 +39,8 @@ impl Request { return Err("detail pass requires an image".into()); } match &self.operation { - Operation::Prepare => {} + Operation::Prepare | Operation::Subject(None) => {} + Operation::Subject(Some(f)) if f.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) => {} Operation::Text(text) if text.len() <= 4096 && text.split([',', ';']).any(|p| !p.trim().is_empty()) => {} Operation::Clicks(clicks) if !clicks.is_empty() @@ -131,6 +135,11 @@ fn predict(model: &mut Sam3, enc: &mut Encoded, operation: Operation) -> Result< Ok(None) } Operation::Text(text) => text_logits(model, enc, &text).map_err(|e| e.to_string()), + // (the reference score threshold, as a local Find Subject uses) + Operation::Subject(focus) => { + let p = model.segment_subject(enc, focus.map(|f| (f[0], f[1])), 0.5).map_err(|e| e.to_string())?; + Ok(p.map(|p| p.logits)) + } Operation::Clicks(points) => { let clicks: Vec<_> = points.into_iter().map(|p| Click { x: p[0], y: p[1], positive: p[2] == 1.0 }).collect(); Ok(Some(model.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?.logits)) diff --git a/crates/segment/tests/it/remote.rs b/crates/segment/tests/it/remote.rs index ee20ea2066..7b612425d3 100644 --- a/crates/segment/tests/it/remote.rs +++ b/crates/segment/tests/it/remote.rs @@ -18,4 +18,10 @@ fn validates_images_and_prompt_boundaries() { assert!(request(4, 3, Operation::Clicks(vec![[0.5, f32::NAN, 1.0]])).validate().is_err()); assert!(request(4, 3, Operation::Clicks(vec![[0.5, 0.5, 0.0]])).validate().is_err()); assert!(request(4, 3, Operation::Text(" ".into())).validate().is_err()); + // Find Subject: with or without the camera's focus point, which must lie in the photo + assert!(request(4, 3, Operation::Subject(None)).validate().is_ok()); + assert!(request(4, 3, Operation::Subject(Some([0.3, 0.7]))).validate().is_ok()); + for bad in [[1.5, 0.5], [0.5, -0.1], [f32::NAN, 0.5], [0.5, f32::INFINITY]] { + assert!(request(4, 3, Operation::Subject(Some(bad))).validate().is_err(), "{bad:?}"); + } } diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index ea10219217..5ba9c5098f 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -2400,6 +2400,34 @@ "Save Current Settings as New Effect Preset…": "Uložit aktuální nastavení jako nové přednastavení efektu…", "Manage Effect Presets…": "Spravovat přednastavení efektů…", "Export Effect Presets…": "Exportovat přednastavení efektů…", + "Blur Amount": "Míra rozostření", + "Boost": "Zesílení", + "In Focus": "Zaostřeno", + "Blur what is out of focus, as a wide aperture would": "Rozostří, co je mimo zaostření, jako by to udělala široce otevřená clona", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Co zůstane ostré: subjekt fotky nebo výběr jedné z jejích masek", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Tip: subjekt přesně vyberte maskou Objekt (Maskování, M) a pak tu masku zvolte zde.", + "Brush Refinement": "Doladění štětcem", + "Blur": "Rozostření", + "Paint what should stay sharp": "Namalujte, co má zůstat ostré", + "Paint what should be blurred": "Namalujte, co se má rozostřit", + "Remove the strokes of both brushes": "Odstranit tahy obou štětců", + "Find Subject": "Najít subjekt", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Vybrat subjekt pomocí AI (SAM 3), od místa, kam zaostřil fotoaparát, pokud ho soubor uvádí, a ponechat ho zaostřený", + "Selecting the subject at the camera's focus point…": "Vybírá se subjekt v bodě zaostření fotoaparátu…", + "Selecting the subject…": "Vybírá se subjekt…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Dolaďte ho v Maskování (M): + → Objekt přidá, co chybí, − → Objekt část vynechá.", + "Finding the subject… The first time on a photo takes a while.": "Hledá se subjekt… Poprvé to u fotky chvíli trvá.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Subjekt nebyl vybrán. Zkuste znovu Najít subjekt, nebo na něj klikněte v Maskování (M).", + "Transition": "Přechod", + "Selection": "Výběr", + "Outside": "Mimo výběr", + "Shape": "Tvar", + "Round": "Kruh", + "Auto: the photo's subject, estimated": "Automaticky: odhadnutý subjekt fotky", + "Horizontal oval": "Vodorovný ovál", + "Vertical oval": "Svislý ovál", + "Move & Resize": "Přesunout a změnit velikost", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Tažením tvaru na fotce ho přesunete; tažením boční nebo spodní úchytky ho roztáhnete. Tlačítko tvaru ho vrátí doprostřed.", "Before/After Side by Side": "Před/Po vedle sebe", "Clipping": "Ořezání tónů", "Mask Overlay": "Překrytí masky", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index bc67606eb6..70fa252dc8 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -3433,6 +3433,34 @@ "Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "Optionales entferntes SAM 3: Der native Editor bleibt lokal, und Objekt-, Beschreibungs- und Detail-", "inference on a Mac through SSH. Saved masks still render and export offline.": "Inferenz laufen über SSH auf einem Mac. Gespeicherte Masken werden weiterhin offline gerendert und exportiert.", "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "Siehe [remote Metal inference](ai-masks.md#remote-metal-inference).", + "Blur Amount": "Unschärfestärke", + "Boost": "Verstärkung", + "In Focus": "Im Fokus", + "Blur what is out of focus, as a wide aperture would": "Zeichnet unscharf, was außerhalb des Fokus liegt, wie eine weit geöffnete Blende", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Was scharf bleibt: das Motiv des Fotos oder die Auswahl einer seiner Masken", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Tipp: Wählen Sie das Motiv mit einer Objekt-Maske (Maskieren, M) genau aus und wählen Sie diese Maske dann hier.", + "Brush Refinement": "Pinselkorrektur", + "Blur": "Unschärfe", + "Paint what should stay sharp": "Malen, was scharf bleiben soll", + "Paint what should be blurred": "Malen, was unscharf werden soll", + "Remove the strokes of both brushes": "Die Striche beider Pinsel entfernen", + "Find Subject": "Motiv finden", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Das Motiv per KI (SAM 3) auswählen, ausgehend vom Fokuspunkt der Kamera, wenn die Datei ihn enthält, und scharf halten", + "Selecting the subject at the camera's focus point…": "Motiv am Fokuspunkt der Kamera wird ausgewählt…", + "Selecting the subject…": "Motiv wird ausgewählt…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "In Maskieren (M) verfeinern: + → Objekt fügt Fehlendes hinzu, − → Objekt lässt einen Teil weg.", + "Finding the subject… The first time on a photo takes a while.": "Motiv wird gesucht… Beim ersten Mal dauert das bei einem Foto eine Weile.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Das Motiv wurde nicht ausgewählt. Versuchen Sie erneut „Motiv finden“ oder klicken Sie es in Maskieren (M) an.", + "Transition": "Übergang", + "Selection": "Auswahl", + "Outside": "Außerhalb", + "Shape": "Form", + "Round": "Rund", + "Auto: the photo's subject, estimated": "Automatisch: das geschätzte Motiv des Fotos", + "Horizontal oval": "Liegendes Oval", + "Vertical oval": "Stehendes Oval", + "Move & Resize": "Verschieben & Größe", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Ziehen Sie die Form auf dem Foto, um sie zu verschieben; ziehen Sie den seitlichen oder unteren Griff, um sie zu dehnen. Eine Form-Schaltfläche setzt sie wieder in die Mitte.", "Before/After Side by Side": "Vorher/Nachher nebeneinander", "Clipping": "Beschneidung", "Mask Overlay": "Maskenüberlagerung", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c34057bdfe..7b07c40a1f 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -2358,6 +2358,34 @@ "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.", "Checking the GPU for faces": "Comprobando la GPU para las caras", + "Blur Amount": "Cantidad de desenfoque", + "Boost": "Realce", + "In Focus": "Enfocado", + "Blur what is out of focus, as a wide aperture would": "Desenfoca lo que está fuera de foco, como lo haría una apertura amplia", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Lo que queda nítido: el sujeto de la foto o la selección de una de sus máscaras", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Consejo: selecciona el sujeto con precisión con una máscara Objeto (Máscaras, M) y luego elige esa máscara aquí.", + "Brush Refinement": "Refinamiento con pincel", + "Blur": "Desenfoque", + "Paint what should stay sharp": "Pinta lo que debe quedar nítido", + "Paint what should be blurred": "Pinta lo que debe desenfocarse", + "Remove the strokes of both brushes": "Quitar los trazos de ambos pinceles", + "Find Subject": "Buscar sujeto", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Selecciona el sujeto con IA (SAM 3), empezando donde enfocó la cámara si el archivo lo registra, y lo mantiene enfocado", + "Selecting the subject at the camera's focus point…": "Seleccionando el sujeto en el punto de enfoque de la cámara…", + "Selecting the subject…": "Seleccionando el sujeto…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Ajústalo en Máscaras (M): + → Objeto añade lo que faltó, − → Objeto excluye una parte.", + "Finding the subject… The first time on a photo takes a while.": "Buscando el sujeto… La primera vez en una foto tarda un poco.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "No se seleccionó el sujeto. Vuelve a probar Buscar sujeto o haz clic en él en Máscaras (M).", + "Transition": "Transición", + "Selection": "Selección", + "Outside": "Fuera", + "Shape": "Forma", + "Round": "Redonda", + "Auto: the photo's subject, estimated": "Automático: el sujeto estimado de la foto", + "Horizontal oval": "Óvalo horizontal", + "Vertical oval": "Óvalo vertical", + "Move & Resize": "Mover y redimensionar", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Arrastra la forma en la foto para moverla; arrastra su tirador lateral o inferior para estirarla. Un botón de forma la devuelve al centro.", "Layout Image": "Imagen de diseño", "Detail View": "Vista de detalle", "Edit Panel": "Panel Editar", diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index 59cc611cd6..f06ce0d4d2 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -2340,6 +2340,34 @@ "≤ at most": "≤ enintään", "≥ at least": "≥ vähintään", "★ Favorites": "★ Suosikit", + "Blur Amount": "Epätarkkuuden määrä", + "Boost": "Korostus", + "In Focus": "Tarkka alue", + "Blur what is out of focus, as a wide aperture would": "Sumentaa tarkennuksen ulkopuolisen alueen, kuten suuri aukko tekisi", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Mikä pysyy tarkkana: kuvan kohde tai jonkin sen maskin valinta", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Vinkki: valitse kohde tarkasti Kohde-maskilla (Maskaus, M) ja valitse sitten se maski tästä.", + "Brush Refinement": "Siveltimellä tarkennus", + "Blur": "Sumennus", + "Paint what should stay sharp": "Maalaa, minkä pitää pysyä tarkkana", + "Paint what should be blurred": "Maalaa, minkä pitää sumentua", + "Remove the strokes of both brushes": "Poista molempien siveltimien vedot", + "Find Subject": "Etsi kohde", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Valitse kohde tekoälyllä (SAM 3) aloittaen kameran tarkennuskohdasta, jos tiedosto kertoo sen, ja pidä se tarkkana", + "Selecting the subject at the camera's focus point…": "Valitaan kohdetta kameran tarkennuspisteestä…", + "Selecting the subject…": "Valitaan kohdetta…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Hienosäädä Maskauksessa (M): + → Kohde lisää puuttuvan, − → Kohde jättää osan pois.", + "Finding the subject… The first time on a photo takes a while.": "Etsitään kohdetta… Ensimmäisellä kerralla kuvassa tämä kestää hetken.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Kohdetta ei valittu. Kokeile Etsi kohde uudelleen tai napsauta sitä Maskauksessa (M).", + "Transition": "Siirtymä", + "Selection": "Valinta", + "Outside": "Ulkopuoli", + "Shape": "Muoto", + "Round": "Pyöreä", + "Auto: the photo's subject, estimated": "Automaattinen: kuvan arvioitu kohde", + "Horizontal oval": "Vaakasuora soikio", + "Vertical oval": "Pystysuora soikio", + "Move & Resize": "Siirrä ja muuta kokoa", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Siirrä muotoa vetämällä sitä kuvassa; venytä sitä vetämällä sivu- tai alakahvasta. Muotopainike palauttaa sen keskelle.", "All Mask Overlays": "Kaikki maskien peittokuvat", "Before/After Side by Side": "Ennen/jälkeen vierekkäin", "Clipping": "Leikkautuminen", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 31e6814b6d..e946c67346 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -2353,6 +2353,34 @@ "CPU": "Processeur", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.", "Checking the GPU for faces": "Vérification du GPU pour les visages", + "Blur Amount": "Quantité de flou", + "Boost": "Accentuation", + "In Focus": "Zone nette", + "Blur what is out of focus, as a wide aperture would": "Floute ce qui est hors de la mise au point, comme le ferait une grande ouverture", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Ce qui reste net : le sujet de la photo ou la sélection de l'un de ses masques", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Astuce : sélectionnez précisément le sujet avec un masque Objet (Masquage, M), puis choisissez ce masque ici.", + "Brush Refinement": "Affinage au pinceau", + "Blur": "Flou", + "Paint what should stay sharp": "Peignez ce qui doit rester net", + "Paint what should be blurred": "Peignez ce qui doit être flou", + "Remove the strokes of both brushes": "Supprimer les traits des deux pinceaux", + "Find Subject": "Trouver le sujet", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Sélectionne le sujet par IA (SAM 3), à partir de l'endroit où l'appareil a fait la mise au point si le fichier l'indique, et le garde net", + "Selecting the subject at the camera's focus point…": "Sélection du sujet au point de mise au point de l'appareil…", + "Selecting the subject…": "Sélection du sujet…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Affinez-le dans Masquage (M) : + → Objet ajoute ce qui manque, − → Objet exclut une partie.", + "Finding the subject… The first time on a photo takes a while.": "Recherche du sujet… La première fois sur une photo, cela prend un moment.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Le sujet n'a pas été sélectionné. Réessayez Trouver le sujet, ou cliquez dessus dans Masquage (M).", + "Transition": "Transition", + "Selection": "Sélection", + "Outside": "Extérieur", + "Shape": "Forme", + "Round": "Ronde", + "Auto: the photo's subject, estimated": "Auto : le sujet estimé de la photo", + "Horizontal oval": "Ovale horizontal", + "Vertical oval": "Ovale vertical", + "Move & Resize": "Déplacer et redimensionner", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Faites glisser la forme sur la photo pour la déplacer ; faites glisser sa poignée latérale ou inférieure pour l'étirer. Un bouton de forme la remet au centre.", "Layout Image": "Image de mise en page", "Detail View": "Vue de détail", "Edit Panel": "Panneau Modifier", diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index 52705328cc..aac5874ad6 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -2353,6 +2353,34 @@ "≥ at least": "≥ minimal", "日本語": "日本語", "繁體中文(台灣)": "繁體中文(台灣)", + "Blur Amount": "Jumlah buram", + "Boost": "Penguatan", + "In Focus": "Dalam fokus", + "Blur what is out of focus, as a wide aperture would": "Memburamkan bagian di luar fokus, seperti bukaan lebar", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Yang tetap tajam: subjek foto, atau seleksi salah satu maskernya", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Tips: pilih subjek dengan tepat memakai masker Objek (Masker, M), lalu pilih masker itu di sini.", + "Brush Refinement": "Penyempurnaan kuas", + "Blur": "Buram", + "Paint what should stay sharp": "Lukis bagian yang harus tetap tajam", + "Paint what should be blurred": "Lukis bagian yang harus diburamkan", + "Remove the strokes of both brushes": "Hapus goresan kedua kuas", + "Find Subject": "Cari subjek", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Pilih subjek dengan AI (SAM 3), mulai dari titik fokus kamera bila file mencatatnya, lalu jaga tetap fokus", + "Selecting the subject at the camera's focus point…": "Memilih subjek di titik fokus kamera…", + "Selecting the subject…": "Memilih subjek…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Sempurnakan di Masker (M): + → Objek menambah yang terlewat, − → Objek mengecualikan bagian.", + "Finding the subject… The first time on a photo takes a while.": "Mencari subjek… Pertama kali pada sebuah foto butuh waktu.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Subjek tidak terpilih. Coba Cari subjek lagi, atau klik subjeknya di Masker (M).", + "Transition": "Transisi", + "Selection": "Seleksi", + "Outside": "Luar", + "Shape": "Bentuk", + "Round": "Bulat", + "Auto: the photo's subject, estimated": "Otomatis: perkiraan subjek foto", + "Horizontal oval": "Oval horizontal", + "Vertical oval": "Oval vertikal", + "Move & Resize": "Pindah & ubah ukuran", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Seret bentuk di foto untuk memindahkannya; seret pegangan samping atau bawahnya untuk merenggangkannya. Tombol bentuk mengembalikannya ke tengah.", "All Mask Overlays": "Semua overlay masker", "Before/After Side by Side": "Sebelum/Sesudah berdampingan", "Clipping": "Clipping", diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 5b0ccc0cee..743b94e890 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2394,6 +2394,34 @@ "Show briefly when photo changes": "Mostra brevemente al cambio di foto", "Camera & Lens": "Fotocamera e obiettivo", "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2.", + "Blur Amount": "Quantità di sfocatura", + "Boost": "Enfasi", + "In Focus": "A fuoco", + "Blur what is out of focus, as a wide aperture would": "Sfoca ciò che è fuori fuoco, come farebbe un diaframma molto aperto", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Cosa resta nitido: il soggetto della foto o la selezione di una delle sue maschere", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Suggerimento: seleziona con precisione il soggetto con una maschera Oggetto (Mascheratura, M), poi scegli qui quella maschera.", + "Brush Refinement": "Rifinitura a pennello", + "Blur": "Sfocatura", + "Paint what should stay sharp": "Dipingi ciò che deve restare nitido", + "Paint what should be blurred": "Dipingi ciò che deve essere sfocato", + "Remove the strokes of both brushes": "Rimuovi le pennellate di entrambi i pennelli", + "Find Subject": "Trova soggetto", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Seleziona il soggetto con l'IA (SAM 3), partendo dal punto di messa a fuoco della fotocamera se il file lo registra, e lo mantiene a fuoco", + "Selecting the subject at the camera's focus point…": "Selezione del soggetto nel punto di messa a fuoco della fotocamera…", + "Selecting the subject…": "Selezione del soggetto…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Rifiniscilo in Mascheratura (M): + → Oggetto aggiunge ciò che manca, − → Oggetto esclude una parte.", + "Finding the subject… The first time on a photo takes a while.": "Ricerca del soggetto… La prima volta su una foto richiede un po' di tempo.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Il soggetto non è stato selezionato. Riprova Trova soggetto, oppure fai clic su di esso in Mascheratura (M).", + "Transition": "Transizione", + "Selection": "Selezione", + "Outside": "Esterno", + "Shape": "Forma", + "Round": "Rotonda", + "Auto: the photo's subject, estimated": "Automatico: il soggetto stimato della foto", + "Horizontal oval": "Ovale orizzontale", + "Vertical oval": "Ovale verticale", + "Move & Resize": "Sposta e ridimensiona", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Trascina la forma sulla foto per spostarla; trascina la maniglia laterale o inferiore per allungarla. Un pulsante di forma la riporta al centro.", "Before/After Side by Side": "Prima/Dopo affiancati", "Clipping": "Tagli tonali", "Mask Overlay": "Sovrapposizione maschera", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 97c15dd019..6c83e8aaa3 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -2356,6 +2356,34 @@ "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。", "Checking the GPU for faces": "顔用の GPU を確認中", + "Blur Amount": "ぼかしの量", + "Boost": "ブースト", + "In Focus": "ピントを合わせる対象", + "Blur what is out of focus, as a wide aperture would": "絞りを大きく開いたように、ピントの外側をぼかします", + "What stays sharp: the photo's subject, or the selection of one of its masks": "シャープに残す部分:写真の被写体、またはいずれかのマスクの選択範囲", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "ヒント:オブジェクトマスク(マスク、M)で被写体を正確に選択してから、ここでそのマスクを選びます。", + "Brush Refinement": "ブラシで調整", + "Blur": "ぼかし", + "Paint what should stay sharp": "シャープに残す部分をペイント", + "Paint what should be blurred": "ぼかす部分をペイント", + "Remove the strokes of both brushes": "両方のブラシのストロークを削除", + "Find Subject": "被写体を検出", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "AI(SAM 3)で被写体を選択します。ファイルにカメラのフォーカス位置があればそこから始め、被写体にピントを保ちます", + "Selecting the subject at the camera's focus point…": "カメラのフォーカス位置で被写体を選択しています…", + "Selecting the subject…": "被写体を選択しています…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "マスク(M)で調整:+ → オブジェクトで不足分を追加、− → オブジェクトで一部を除外します。", + "Finding the subject… The first time on a photo takes a while.": "被写体を探しています… 写真ごとに初回は時間がかかります。", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "被写体を選択できませんでした。もう一度「被写体を検出」を試すか、マスク(M)で被写体をクリックしてください。", + "Transition": "トランジション", + "Selection": "選択範囲", + "Outside": "外側", + "Shape": "形", + "Round": "円形", + "Auto: the photo's subject, estimated": "自動:推定した写真の被写体", + "Horizontal oval": "横長の楕円", + "Vertical oval": "縦長の楕円", + "Move & Resize": "移動とサイズ変更", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "写真上の形をドラッグして移動します。横または下のハンドルをドラッグして伸ばします。形のボタンで中央に戻ります。", "Layout Image": "レイアウト画像", "Detail View": "詳細表示", "Edit Panel": "編集パネル", diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 30769b60fa..423ea1cc47 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -2751,6 +2751,34 @@ "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.", "日本語": "日本語", "繁體中文(台灣)": "繁體中文(台灣)", + "Blur Amount": "ბუნდოვნების რაოდენობა", + "Boost": "გაძლიერება", + "In Focus": "ფოკუსში", + "Blur what is out of focus, as a wide aperture would": "აბუნდოვნებს იმას, რაც ფოკუსის გარეთაა, როგორც ფართოდ გახსნილი დიაფრაგმა", + "What stays sharp: the photo's subject, or the selection of one of its masks": "რა რჩება მკვეთრი: ფოტოს საგანი ან მისი ერთ-ერთი ნიღბის მონიშვნა", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "რჩევა: საგანი ზუსტად მონიშნეთ ობიექტის ნიღბით (ნიღბვა, M), შემდეგ კი აქ აირჩიეთ ეს ნიღაბი.", + "Brush Refinement": "ფუნჯით დახვეწა", + "Blur": "ბუნდოვნება", + "Paint what should stay sharp": "მოხატეთ ის, რაც მკვეთრი უნდა დარჩეს", + "Paint what should be blurred": "მოხატეთ ის, რაც უნდა გაბუნდოვნდეს", + "Remove the strokes of both brushes": "ორივე ფუნჯის შტრიხების წაშლა", + "Find Subject": "საგნის პოვნა", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "საგნის მონიშვნა AI-ით (SAM 3) იქიდან, სადაც კამერამ ფოკუსირება მოახდინა, თუ ფაილი ამას ინახავს, და მისი ფოკუსში დატოვება", + "Selecting the subject at the camera's focus point…": "საგანი ინიშნება კამერის ფოკუსის წერტილში…", + "Selecting the subject…": "საგანი ინიშნება…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "დახვეწეთ ნიღბვაში (M): + → ობიექტი ამატებს გამორჩენილს, − → ობიექტი ნაწილს გამორიცხავს.", + "Finding the subject… The first time on a photo takes a while.": "საგანი იძებნება… ფოტოზე პირველად ამას გარკვეული დრო სჭირდება.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "საგანი ვერ მოინიშნა. სცადეთ „საგნის პოვნა“ ხელახლა, ან დააწკაპეთ მასზე ნიღბვაში (M).", + "Transition": "გადასვლა", + "Selection": "მონიშნული", + "Outside": "გარეთ", + "Shape": "ფორმა", + "Round": "მრგვალი", + "Auto: the photo's subject, estimated": "ავტომატური: ფოტოს სავარაუდო საგანი", + "Horizontal oval": "ჰორიზონტალური ოვალი", + "Vertical oval": "ვერტიკალური ოვალი", + "Move & Resize": "გადატანა და ზომის შეცვლა", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "გადაიტანეთ ფორმა ფოტოზე მის გადასაადგილებლად; გაწიეთ გვერდითი ან ქვედა სახელური მის გასაჭიმად. ფორმის ღილაკი მას შუაში დააბრუნებს.", "Focus Point": "ფოკუსის წერტილი", "Layout Image": "განლაგების სურათი", "Detail View": "დეტალური ხედი", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index dbc29a1941..c4954eb3a8 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -2275,6 +2275,35 @@ "SDR Whites": "Biele SDR", "SDR Clarity": "Przejrzystość SDR", "Face Models on the GPU": "Modele twarzy przetwarzane na GPU", + "Blur Amount": "Siła rozmycia", + "Boost": "Wzmocnienie", + "In Focus": "Ostrość na", + "Blur what is out of focus, as a wide aperture would": "Rozmywa to, co jest poza ostrością, jak szeroko otwarta przysłona", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Co pozostaje ostre: główny obiekt zdjęcia lub zaznaczenie jednej z jego masek", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Wskazówka: precyzyjnie zaznacz obiekt maską Obiekt (Maskowanie, M), a potem wybierz tę maskę tutaj.", + "Brush Refinement": "Korekta pędzlem", + "Focus": "Ostrość", + "Blur": "Rozmycie", + "Paint what should stay sharp": "Zamaluj to, co ma pozostać ostre", + "Paint what should be blurred": "Zamaluj to, co ma zostać rozmyte", + "Remove the strokes of both brushes": "Usuń pociągnięcia obu pędzli", + "Find Subject": "Znajdź obiekt", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Zaznacz główny obiekt za pomocą AI (SAM 3), zaczynając od punktu ostrości aparatu, jeśli plik go zapisuje, i zachowaj go ostrym", + "Selecting the subject at the camera's focus point…": "Zaznaczanie obiektu w punkcie ostrości aparatu…", + "Selecting the subject…": "Zaznaczanie obiektu…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Popraw w Maskowaniu (M): + → Obiekt dodaje to, czego brakuje, − → Obiekt pomija część.", + "Finding the subject… The first time on a photo takes a while.": "Wyszukiwanie obiektu… Za pierwszym razem na zdjęciu trwa to chwilę.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Nie zaznaczono obiektu. Spróbuj ponownie Znajdź obiekt lub kliknij go w Maskowaniu (M).", + "Transition": "Przejście", + "Selection": "Zaznaczenie", + "Outside": "Na zewnątrz", + "Shape": "Kształt", + "Round": "Okrągły", + "Auto: the photo's subject, estimated": "Automatycznie: szacowany główny obiekt zdjęcia", + "Horizontal oval": "Owal poziomy", + "Vertical oval": "Owal pionowy", + "Move & Resize": "Przesuń i zmień rozmiar", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Przeciągnij kształt na zdjęciu, aby go przesunąć; przeciągnij uchwyt boczny lub dolny, aby go rozciągnąć. Przycisk kształtu przywraca go na środek.", "Before/After Side by Side": "Przed/Po obok siebie", "Clipping": "Prześwietlenia i niedoświetlenia", "Mask Overlay": "Nakładka maski", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 3aedd82906..c1e59c59a4 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2202,6 +2202,34 @@ "Image on Black": "Imagem sobre preto", "Image on White": "Imagem sobre branco", "White on Black": "Branco sobre preto", + "Blur Amount": "Intensidade do desfoque", + "Boost": "Realce", + "In Focus": "Em foco", + "Blur what is out of focus, as a wide aperture would": "Desfoca o que está fora de foco, como faria uma abertura ampla", + "What stays sharp: the photo's subject, or the selection of one of its masks": "O que fica nítido: o assunto da foto ou a seleção de uma de suas máscaras", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Dica: selecione o assunto com precisão usando uma máscara Objeto (Máscara, M) e escolha essa máscara aqui.", + "Brush Refinement": "Refinamento com pincel", + "Blur": "Desfoque", + "Paint what should stay sharp": "Pinte o que deve ficar nítido", + "Paint what should be blurred": "Pinte o que deve ser desfocado", + "Remove the strokes of both brushes": "Remover os traços dos dois pincéis", + "Find Subject": "Encontrar assunto", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Seleciona o assunto com IA (SAM 3), começando onde a câmera focou quando o arquivo registra isso, e o mantém em foco", + "Selecting the subject at the camera's focus point…": "Selecionando o assunto no ponto de foco da câmera…", + "Selecting the subject…": "Selecionando o assunto…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Refine em Máscara (M): + → Objeto acrescenta o que faltou, − → Objeto deixa uma parte de fora.", + "Finding the subject… The first time on a photo takes a while.": "Encontrando o assunto… Na primeira vez em uma foto, isso demora um pouco.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "O assunto não foi selecionado. Tente Encontrar assunto de novo ou clique nele em Máscara (M).", + "Transition": "Transição", + "Selection": "Seleção", + "Outside": "Fora", + "Shape": "Formato", + "Round": "Redondo", + "Auto: the photo's subject, estimated": "Automático: o assunto estimado da foto", + "Horizontal oval": "Oval horizontal", + "Vertical oval": "Oval vertical", + "Move & Resize": "Mover e redimensionar", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Arraste o formato na foto para movê-lo; arraste a alça lateral ou inferior para esticá-lo. Um botão de formato o devolve ao centro.", "Layout Image": "Imagem de layout", "Detail View": "Exibição de detalhe", "Edit Panel": "Painel Editar", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index b3df02470e..258dae60bf 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2564,6 +2564,34 @@ "CPU": "ЦП", "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.", "Checking the GPU for faces": "Проверка ГП для лиц", + "Blur Amount": "Сила размытия", + "Boost": "Усиление", + "In Focus": "В фокусе", + "Blur what is out of focus, as a wide aperture would": "Размывает то, что вне фокуса, как широко открытая диафрагма", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Что остаётся резким: главный объект фото или выделение одной из его масок", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Совет: точно выделите объект маской «Объект» (Маски, M), затем выберите эту маску здесь.", + "Brush Refinement": "Уточнение кистью", + "Blur": "Размытие", + "Paint what should stay sharp": "Закрасьте то, что должно остаться резким", + "Paint what should be blurred": "Закрасьте то, что нужно размыть", + "Remove the strokes of both brushes": "Удалить мазки обеих кистей", + "Find Subject": "Найти главный объект", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Выделить главный объект с помощью ИИ (SAM 3), начиная с точки фокусировки камеры, если файл её хранит, и оставить его в фокусе", + "Selecting the subject at the camera's focus point…": "Выделяется объект в точке фокусировки камеры…", + "Selecting the subject…": "Выделяется объект…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Уточните в Масках (M): + → Объект добавляет пропущенное, − → Объект исключает часть.", + "Finding the subject… The first time on a photo takes a while.": "Поиск главного объекта… В первый раз на снимке это занимает время.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Объект не выделен. Попробуйте «Найти главный объект» ещё раз или щёлкните по нему в Масках (M).", + "Transition": "Переход", + "Selection": "Выделение", + "Outside": "Снаружи", + "Shape": "Форма", + "Round": "Круг", + "Auto: the photo's subject, estimated": "Авто: предполагаемый главный объект снимка", + "Horizontal oval": "Горизонтальный овал", + "Vertical oval": "Вертикальный овал", + "Move & Resize": "Переместить и изменить размер", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Перетащите форму на снимке, чтобы переместить её; потяните за боковой или нижний маркер, чтобы растянуть. Кнопка формы вернёт её в центр.", "Layout Image": "Изображение макета", "Detail View": "Режим детализации", "Edit Panel": "Панель «Правка»", diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index 94b9e30333..8b454e0b2d 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -2437,6 +2437,35 @@ "★ Favorites": "★ Favoriter", "日本語": "日本語", "繁體中文(台灣)": "繁體中文(台灣)", + "Blur Amount": "Oskärpemängd", + "Boost": "Förstärkning", + "In Focus": "I fokus", + "Blur what is out of focus, as a wide aperture would": "Gör det som är utanför fokus oskarpt, som en stor bländaröppning", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Det som förblir skarpt: fotots motiv eller markeringen i en av dess masker", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Tips: markera motivet exakt med en Objekt-mask (Maskering, M) och välj sedan den masken här.", + "Brush Refinement": "Penselförfining", + "Focus": "Fokus", + "Blur": "Oskärpa", + "Paint what should stay sharp": "Måla det som ska förbli skarpt", + "Paint what should be blurred": "Måla det som ska bli oskarpt", + "Remove the strokes of both brushes": "Ta bort penseldragen från båda penslarna", + "Find Subject": "Hitta motiv", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Markera motivet med AI (SAM 3) med start där kameran fokuserade, om filen anger det, och håll det i fokus", + "Selecting the subject at the camera's focus point…": "Markerar motivet vid kamerans fokuspunkt…", + "Selecting the subject…": "Markerar motivet…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Förfina i Maskering (M): + → Objekt lägger till det som saknas, − → Objekt utesluter en del.", + "Finding the subject… The first time on a photo takes a while.": "Söker motivet… Första gången i ett foto tar det en stund.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Motivet markerades inte. Försök med Hitta motiv igen, eller klicka på det i Maskering (M).", + "Transition": "Övergång", + "Selection": "Markering", + "Outside": "Utanför", + "Shape": "Form", + "Round": "Rund", + "Auto: the photo's subject, estimated": "Auto: fotots uppskattade motiv", + "Horizontal oval": "Vågrät oval", + "Vertical oval": "Lodrät oval", + "Move & Resize": "Flytta och ändra storlek", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Dra formen i fotot för att flytta den; dra i dess sido- eller nederhandtag för att sträcka den. En formknapp sätter tillbaka den i mitten.", "All Mask Overlays": "Alla masköverlägg", "Before/After Side by Side": "Före/efter sida vid sida", "Clipping": "Klippning", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 09b78fab1e..b4d3e50020 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2354,6 +2354,34 @@ "CPU": "ЦП", "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.", "Checking the GPU for faces": "Перевірка ГП для облич", + "Blur Amount": "Сила розмиття", + "Boost": "Підсилення", + "In Focus": "У фокусі", + "Blur what is out of focus, as a wide aperture would": "Розмиває те, що поза фокусом, як широко відкрита діафрагма", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Що лишається різким: головний об’єкт фото або виділення однієї з його масок", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Порада: точно виділіть об’єкт маскою «Об’єкт» (Маскування, M), а потім виберіть цю маску тут.", + "Brush Refinement": "Уточнення пензлем", + "Blur": "Розмиття", + "Paint what should stay sharp": "Зафарбуйте те, що має лишитися різким", + "Paint what should be blurred": "Зафарбуйте те, що треба розмити", + "Remove the strokes of both brushes": "Видалити мазки обох пензлів", + "Find Subject": "Знайти об’єкт", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Виділити головний об’єкт за допомогою ШІ (SAM 3), починаючи з точки фокусування камери, якщо файл її зберігає, і залишити його у фокусі", + "Selecting the subject at the camera's focus point…": "Виділяється об’єкт у точці фокусування камери…", + "Selecting the subject…": "Виділяється об’єкт…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Уточніть у Маскуванні (M): + → Об’єкт додає пропущене, − → Об’єкт виключає частину.", + "Finding the subject… The first time on a photo takes a while.": "Пошук об’єкта… Уперше на знімку це триває певний час.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Об’єкт не виділено. Спробуйте «Знайти об’єкт» ще раз або клацніть на ньому в Маскуванні (M).", + "Transition": "Перехід", + "Selection": "Виділення", + "Outside": "Зовні", + "Shape": "Форма", + "Round": "Коло", + "Auto: the photo's subject, estimated": "Авто: імовірний головний об’єкт фото", + "Horizontal oval": "Горизонтальний овал", + "Vertical oval": "Вертикальний овал", + "Move & Resize": "Перемістити й змінити розмір", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Перетягніть форму на фото, щоб перемістити її; потягніть боковий або нижній маркер, щоб розтягнути. Кнопка форми поверне її в центр.", "Layout Image": "Зображення макета", "Detail View": "Детальне подання", "Edit Panel": "Панель «Редагування»", diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index af27e9aa0a..5840e84b5f 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -2246,6 +2246,34 @@ "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-kéo để chỉnh thẳng, ⇧G Chỉnh thẳng có hướng dẫn, lưới khi biến đổi.", "日本語": "日本語", "繁體中文(台灣)": "繁體中文(台灣)", + "Blur Amount": "Mức làm mờ", + "Boost": "Tăng cường", + "In Focus": "Vùng nét", + "Blur what is out of focus, as a wide aperture would": "Làm mờ phần nằm ngoài vùng lấy nét, như khi mở khẩu độ lớn", + "What stays sharp: the photo's subject, or the selection of one of its masks": "Phần giữ nét: chủ thể của ảnh hoặc vùng chọn của một trong các mặt nạ", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "Mẹo: chọn chính xác chủ thể bằng mặt nạ Đối tượng (Mặt nạ, M), rồi chọn mặt nạ đó tại đây.", + "Brush Refinement": "Tinh chỉnh bằng cọ", + "Blur": "Làm mờ", + "Paint what should stay sharp": "Tô vùng cần giữ nét", + "Paint what should be blurred": "Tô vùng cần làm mờ", + "Remove the strokes of both brushes": "Xóa nét vẽ của cả hai cọ", + "Find Subject": "Tìm chủ thể", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "Chọn chủ thể bằng AI (SAM 3), bắt đầu từ điểm lấy nét của máy ảnh nếu tệp có ghi, và giữ chủ thể nét", + "Selecting the subject at the camera's focus point…": "Đang chọn chủ thể tại điểm lấy nét của máy ảnh…", + "Selecting the subject…": "Đang chọn chủ thể…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "Tinh chỉnh trong Mặt nạ (M): + → Đối tượng thêm phần còn thiếu, − → Đối tượng bỏ một phần.", + "Finding the subject… The first time on a photo takes a while.": "Đang tìm chủ thể… Lần đầu trên một ảnh sẽ mất một lúc.", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "Chưa chọn được chủ thể. Thử Tìm chủ thể lần nữa, hoặc nhấp vào chủ thể trong Mặt nạ (M).", + "Transition": "Chuyển tiếp", + "Selection": "Vùng chọn", + "Outside": "Bên ngoài", + "Shape": "Hình dạng", + "Round": "Tròn", + "Auto: the photo's subject, estimated": "Tự động: chủ thể ước tính của ảnh", + "Horizontal oval": "Hình bầu dục ngang", + "Vertical oval": "Hình bầu dục dọc", + "Move & Resize": "Di chuyển & đổi kích thước", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "Kéo hình trên ảnh để di chuyển; kéo tay nắm bên hoặc dưới để kéo giãn. Nút hình dạng đưa nó về giữa.", "All Mask Overlays": "Mọi lớp phủ mặt nạ", "Before/After Side by Side": "Trước/Sau cạnh nhau", "Clipping": "Vùng cắt tông", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ca13d782a5..ec20786825 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2194,6 +2194,34 @@ "This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。", "Detect Faces (Photo menu) needs it: a small one-time download.": "“检测人脸”(照片菜单)依赖它:一次性小文件下载。", "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)", + "Blur Amount": "模糊量", + "Boost": "增强", + "In Focus": "对焦范围", + "Blur what is out of focus, as a wide aperture would": "像大光圈一样模糊焦外区域", + "What stays sharp: the photo's subject, or the selection of one of its masks": "保持清晰的部分:照片的主体,或其某个蒙版的选区", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "提示:先用“对象”蒙版(蒙版,M)精确选择主体,再在此处选择该蒙版。", + "Brush Refinement": "画笔调整", + "Blur": "模糊", + "Paint what should stay sharp": "涂抹需要保持清晰的部分", + "Paint what should be blurred": "涂抹需要模糊的部分", + "Remove the strokes of both brushes": "移除两种画笔的笔触", + "Find Subject": "查找主体", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "使用 AI(SAM 3)选择主体:若文件记录了相机的对焦点,则从该处开始,并保持主体清晰", + "Selecting the subject at the camera's focus point…": "正在相机对焦点处选择主体…", + "Selecting the subject…": "正在选择主体…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "在蒙版(M)中细化:+ → 对象 添加遗漏部分,− → 对象 排除某部分。", + "Finding the subject… The first time on a photo takes a while.": "正在查找主体… 每张照片第一次需要一些时间。", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "未能选中主体。请再次尝试“查找主体”,或在蒙版(M)中单击主体。", + "Transition": "过渡", + "Selection": "选区", + "Outside": "外部", + "Shape": "形状", + "Round": "圆形", + "Auto: the photo's subject, estimated": "自动:估计的照片主体", + "Horizontal oval": "水平椭圆", + "Vertical oval": "垂直椭圆", + "Move & Resize": "移动和调整大小", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "在照片上拖动形状以移动它;拖动其侧边或底部手柄以拉伸。形状按钮会让它回到中间。", "Before/After Side by Side": "修改前/修改后左右排列", "Clipping": "溢出警告", "Mask Overlay": "蒙版叠加", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f3dfc422ec..f84f851102 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -2158,6 +2158,34 @@ "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。", "Checking the GPU for faces": "正在檢查用於臉孔的 GPU", + "Blur Amount": "模糊量", + "Boost": "增強", + "In Focus": "對焦範圍", + "Blur what is out of focus, as a wide aperture would": "像大光圈一樣模糊焦外區域", + "What stays sharp: the photo's subject, or the selection of one of its masks": "保持清晰的部分:相片的主體,或其某個遮色片的選取範圍", + "Tip: select the subject precisely with an Object mask (Masking, M), then choose that mask here.": "提示:先用「物件」遮色片(遮色片,M)精確選取主體,再於此處選擇該遮色片。", + "Brush Refinement": "筆刷調整", + "Blur": "模糊", + "Paint what should stay sharp": "塗抹需要保持清晰的部分", + "Paint what should be blurred": "塗抹需要模糊的部分", + "Remove the strokes of both brushes": "移除兩種筆刷的筆觸", + "Find Subject": "尋找主體", + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus": "使用 AI(SAM 3)選取主體:若檔案記錄了相機的對焦點,則從該處開始,並保持主體清晰", + "Selecting the subject at the camera's focus point…": "正在相機對焦點選取主體…", + "Selecting the subject…": "正在選取主體…", + "Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.": "在遮色片(M)中調整:+ → 物件 加入遺漏部分,− → 物件 排除某部分。", + "Finding the subject… The first time on a photo takes a while.": "正在尋找主體… 每張相片第一次需要一些時間。", + "The subject wasn't selected. Try Find Subject again, or click it in Masking (M).": "未能選取主體。請再次嘗試「尋找主體」,或在遮色片(M)中按一下主體。", + "Transition": "過渡", + "Selection": "選取範圍", + "Outside": "外部", + "Shape": "形狀", + "Round": "圓形", + "Auto: the photo's subject, estimated": "自動:估計的相片主體", + "Horizontal oval": "水平橢圓", + "Vertical oval": "垂直橢圓", + "Move & Resize": "移動和調整大小", + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.": "在相片上拖曳形狀以移動它;拖曳其側邊或底部控點以拉伸。形狀按鈕會讓它回到中間。", "Layout Image": "版面圖像", "Detail View": "詳細檢視", "Edit Panel": "編輯面板", diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index f4398d2842..e9deed4601 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -162,6 +162,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), + ("section.lensBlur", "Lens Blur", Some("Cmd+6"), "Window>Edit Sections"), ("section.calibration", "Calibration", None, ""), // {on?}; no params toggles. Opening one Edit section closes the others. ("view.soloSections", "Solo Mode", None, "Window>Edit Sections"), diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 71b0ef8b60..2a6a83fd8e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -18,6 +18,7 @@ mod crop; mod filmstrip; mod geometry; mod info; +mod lens_blur; mod look_ahead; mod masks; mod retouch; @@ -48,6 +49,7 @@ use info::{ preview_only_pill, quick_name, }; pub(crate) use info::{draw_focus_point, failed_pill, loupe_pill, rendering_overdue}; +use lens_blur::{lens_blur_brush_overlay, lens_blur_shape_overlay}; use look_ahead::BEHIND; pub use look_ahead::LookAhead; pub(crate) use look_ahead::{Prepare, look_ahead}; @@ -59,6 +61,12 @@ use window::{WindowCtx, WindowView, draw_window, request_window}; /// An in-progress on-canvas gesture. #[derive(Clone, Debug)] pub enum Gesture { + /// Lens Blur's fixed focus shape: moving it (`handle` 0) or stretching it sideways (1) or + /// up and down (2), from `original` (where it was when the drag began). + LensBlurArea { + handle: u8, + original: lightcraft_develop::FocusArea, + }, Brush { points: Vec, /// Pen pressure (0..1) at each point; empty when no pen pressure was reported (a mouse) or the @@ -758,6 +766,8 @@ fn tool_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Res RightPanel::Masking => mask_overlay(app, ui, resp, map, d), RightPanel::Remove => remove_overlay(app, ui, resp, map, d), RightPanel::RedEye => eye_overlay(app, ui, resp, map, d), + RightPanel::Edit if app.ui.tool == "lensBlurShape" => lens_blur_shape_overlay(app, ui, resp, map, d), + RightPanel::Edit if app.ui.tool.starts_with("lensBlur") => lens_blur_brush_overlay(app, ui, resp, map), _ => { // side by side / stacked: Before shows the same frame, so a click in its view (white // balance, colour samples, targeted adjustment) means the same point of the photo. diff --git a/crates/ui-egui/src/panels/detail/lens_blur.rs b/crates/ui-egui/src/panels/detail/lens_blur.rs new file mode 100644 index 0000000000..f3c5404ece --- /dev/null +++ b/crates/ui-egui/src/panels/detail/lens_blur.rs @@ -0,0 +1,148 @@ +//! Lens Blur's tools on the photo: moving and resizing its fixed focus shape, and painting its +//! Focus / Blur refinement brushes. + +use super::geometry::CanvasMap; +use super::masks::{frame_long_norm, pen_force, pin, record_pressure}; +use super::{Color32, DevelopSettings, Gesture, LightcraftApp, Point, Pos2, Rect, Stroke, json, register, vec2}; + +/// Lens Blur's fixed focus shape on the photo (Edit Shape): its outline, a centre pin that moves +/// it (or drag anywhere inside), and handles on its right and bottom edges that stretch it +/// sideways or up and down. One drag is one undo step. +pub(super) fn lens_blur_shape_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { + let Some(area) = d.lens_blur.focus_area(d.crop_center()) else { return }; + let clip = app.canvas_rect.unwrap_or(map.rect); + let p = &ui.painter_at(clip); + let l = frame_long_norm(map); + let at = |dx: f64, dy: f64| map.screen(Point::new(area.center.x + dx * l.0, area.center.y + dy * l.1)); + let outline: Vec = (0..72) + .map(|i| { + let a = i as f64 / 72.0 * std::f64::consts::TAU; + at(area.rx * a.cos(), area.ry * a.sin()) + }) + .collect(); + // a dark keyline under a light line: visible over bright and dark photos alike + p.add(egui::Shape::closed_line(outline.clone(), Stroke::new(3.0, Color32::from_black_alpha(140)))); + p.add(egui::Shape::closed_line(outline, Stroke::new(1.5, Color32::from_white_alpha(235)))); + let (c, hx, hy) = (at(0.0, 0.0), at(area.rx, 0.0), at(0.0, area.ry)); + pin(p, c, true); + for h in [hx, hy] { + p.circle_filled(h, 5.5, Color32::WHITE); + p.circle_stroke(h, 5.5, Stroke::new(1.5, Color32::from_black_alpha(160))); + } + for (name, q) in [("center", c), ("width", hx), ("height", hy)] { + register(ui, format!("lensBlurArea:{name}"), Rect::from_center_size(q, vec2(14.0, 14.0))); + } + // which part a point grabs: a handle, else the inside + let hit = |q: Pos2| -> Option { + if q.distance(hx) < 10.0 { + return Some(1); + } + if q.distance(hy) < 10.0 { + return Some(2); + } + let n = map.norm(q); + let (ex, ey) = ((n.x - area.center.x) / (area.rx * l.0).max(1e-9), (n.y - area.center.y) / (area.ry * l.1).max(1e-9)); + (q.distance(c) < 10.0 || ex * ex + ey * ey <= 1.0).then_some(0) + }; + if let Some(q) = resp.hover_pos() { + let icon = match hit(q) { + Some(1) => Some(egui::CursorIcon::ResizeHorizontal), + Some(2) => Some(egui::CursorIcon::ResizeVertical), + Some(_) => Some(egui::CursorIcon::Move), + None => None, + }; + if let Some(icon) = icon { + ui.ctx().set_cursor_icon(icon); + } + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + && let Some(handle) = hit(q) + { + let _ = app.run("develop.beginInteraction", json!({"label": "Lens Blur Shape"})); + app.gesture = Some(Gesture::LensBlurArea { handle, original: area }); + } + if resp.dragged() + && let (Some(Gesture::LensBlurArea { handle, original }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos()) + { + let n = map.norm(q); + let n0 = map.norm(ui.input(|i| i.pointer.press_origin()).unwrap_or(q)); + let moved = match handle { + 0 => lightcraft_develop::FocusArea { center: Point::new(original.center.x + n.x - n0.x, original.center.y + n.y - n0.y), ..original }, + 1 => lightcraft_develop::FocusArea { rx: (n.x - original.center.x).abs() / l.0.max(1e-9), ..original }, + _ => lightcraft_develop::FocusArea { ry: (n.y - original.center.y).abs() / l.1.max(1e-9), ..original }, + } + .sanitized(); + let _ = app.run("develop.merge", json!({"settings": {"lens_blur": {"area": moved}}, "label": "Lens Blur Shape"})); + } + if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::LensBlurArea { .. })) { + app.gesture = None; + let _ = app.run("develop.endInteraction", json!({})); + } +} + +/// Lens Blur Brush Refinement on the photo: the brush circle (size and feather), the stroke being +/// painted (green: Focus, red: Blur), committed as `lensBlur.brushStroke` when the drag ends. +/// ⌥ (Alt) erases what the brush painted. +pub(super) fn lens_blur_brush_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap) { + let long = (map.rect.width().max(map.rect.height())) as f64; + let clip = app.canvas_rect.unwrap_or(map.rect); + let p = &ui.painter_at(clip); + let focus = app.ui.tool == "lensBlurFocus"; + let r = (app.ui.brush_size as f64 * long) as f32; + if let Some(h) = resp.hover_pos() { + ui.ctx().set_cursor_icon(egui::CursorIcon::None); + p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE)); + p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120))); + } + if let Some(q) = resp.interact_pointer_pos() + && (resp.drag_started() || resp.dragged() || resp.clicked()) + { + let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; + match &mut app.gesture { + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } + } + } + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { + let screen: Vec = points.iter().map(|q| map.screen(*q)).collect(); + let params = crate::brush_live::LiveParams { + clip, + scale: ui.ctx().pixels_per_point(), + r, + hard: r * (1.0 - (app.ui.brush_feather / 100.0).clamp(0.0, 1.0)), + flow: (app.ui.brush_flow / 100.0).clamp(0.0, 1.0), + density: (app.ui.lens_blur_brush_amount / 100.0).clamp(0.0, 1.0), + color: if focus { [70, 200, 120] } else { [230, 70, 70] }, + opacity: 0.45, + }; + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); + } else { + app.brush_live = None; + } + if (resp.drag_stopped() || resp.clicked()) + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() + { + app.brush_live = None; + let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); + let erase = resp.ctx.input(|i| i.modifiers.alt); + let r = app.run( + "lensBlur.brushStroke", + json!({"mode": if focus { "focus" } else { "blur" }, "points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, + "flow": app.ui.brush_flow, "amount": app.ui.lens_blur_brush_amount, "erase": erase, "autoMask": app.ui.brush_auto_mask, + "pressure": pressure}), + ); + if let Err(e) = r { + app.toast(ui.ctx(), e); + } + } +} diff --git a/crates/ui-egui/src/panels/detail/masks.rs b/crates/ui-egui/src/panels/detail/masks.rs index ebcb35e611..1148d9527b 100644 --- a/crates/ui-egui/src/panels/detail/masks.rs +++ b/crates/ui-egui/src/panels/detail/masks.rs @@ -879,7 +879,7 @@ fn current_on_photo(app: &LightcraftApp, mask: u32) -> Option { } /// Long-edge units → normalized units for x and y. -fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { +pub(super) fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { // derive from the mapping: 1 normalized unit in x and y in screen points (of the frame's // proportions, which a lens or perspective warp doesn't change) let map = map.for_mask(false); diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 195bcb6488..15f2f277f3 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -21,6 +21,7 @@ mod curve; mod grading; mod hdr_flow; mod histogram; +mod lens_blur; mod mixer; mod sliders; @@ -31,6 +32,7 @@ pub(crate) use grading::wheel_pointer_frame_end; pub(crate) use grading::{WheelGrab, WheelLock, WheelMods, wheel_pick}; pub(crate) use histogram::histogram; use histogram::soft_proofing; +use lens_blur::lens_blur; use mixer::{mixer, point_color}; use sliders::{REL_TEMP, REL_TINT, hdr_row, header_buttons, k_to_rel, profile_menu, quick_develop, rel_to_k, reset_tip, section}; pub use sliders::{apply_slider_out, sub_title}; @@ -374,6 +376,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } ui.add_space(8.0); }); + section(app, ui, &d, "lensBlur", "Lens Blur", |app, ui, d| { + lens_blur(app, ui, d); + ui.add_space(8.0); + }); // Lightroom Classic's Calibration panel (the cloud app hides it): last, like there. section(app, ui, &d, "calibration", "Calibration", |app, ui, d| { sub_title(ui, crate::i18n::tr("Shadows")); diff --git a/crates/ui-egui/src/panels/edit/lens_blur.rs b/crates/ui-egui/src/panels/edit/lens_blur.rs new file mode 100644 index 0000000000..b7aa696736 --- /dev/null +++ b/crates/ui-egui/src/panels/edit/lens_blur.rs @@ -0,0 +1,257 @@ +//! The Lens Blur section: Apply, the sliders, what stays in focus (the subject, a mask or a fixed +//! shape that can be moved on the photo), Find Subject and Brush Refinement. + +use super::{ + Color32, ControlSpec, Icon, LightcraftApp, Pos2, Rect, RichText, Section, Sense, Stroke, Tokens, Track, control, paint, pos2, register, slider, + sub_title, text_button, vec2, +}; +use lightcraft_develop::DevelopSettings; +use serde_json::json; + +/// The Lens Blur section: Apply, Blur Amount, Boost, and what stays in focus (the photo's subject, +/// or one of its masks, e.g. an Object mask that selected it). +pub(super) fn lens_blur(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { + let t = Tokens::get(ui.ctx()); + let b = &d.lens_blur; + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| { + let mut on = b.enabled; + let r = ui.checkbox(&mut on, crate::i18n::tr("Apply")); + register(ui, "check:lensBlur.enabled", r.rect); + if r.on_hover_text(crate::i18n::tr("Blur what is out of focus, as a wide aperture would")).changed() { + let mut patch = json!({"enabled": on}); + // switched on the first time: a visible starting amount, as in Lightroom + if on && b.amount == 0.0 { + patch["amount"] = json!(50.0); + } + let _ = app.run("develop.merge", json!({"settings": {"lens_blur": patch}, "label": "Lens Blur"})); + } + }); + control(app, ui, d, "lensBlur.amount", b.enabled); + control(app, ui, d, "lensBlur.boost", b.enabled); + control(app, ui, d, "lensBlur.transition", b.enabled); + // the focus controls do nothing while the blur is off: shown, but not usable + let has_mask = b.focus_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)).is_some(); + if app.ui.tool == "lensBlurShape" && (!b.enabled || has_mask || b.shape == lightcraft_develop::FocusShape::Auto) { + app.ui.tool.clear(); + } + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 4 }).show(ui, |ui| ui.add_enabled_ui(b.enabled, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("In Focus")).font(t.font(13.0)).color(t.text_dim)); + let chosen = b.focus_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)); + // without a mask: the subject estimate, or the fixed shape chosen below + let auto = b.shape == lightcraft_develop::FocusShape::Auto; + let none = if auto { crate::i18n::tr("Subject") } else { crate::i18n::tr(b.shape.label()) }; + let shown = chosen.map_or_else(|| none.to_string(), |m| m.name.clone()); + let r = crate::widgets::dropdown(ui, "lensBlurFocus", &shown, t.font(14.0), t.text_label); + let r = r.on_hover_text(crate::i18n::tr("What stays sharp: the photo's subject, or the selection of one of its masks")); + let mut pick: Option> = None; + egui::Popup::menu(&r).show(|ui| { + let item = ui.selectable_label(chosen.is_none() && auto, crate::i18n::tr("Subject")); + register(ui, "lensBlurFocus:subject", item.rect); + if item.clicked() { + pick = Some(None); + } + for m in &d.masks { + let item = ui.selectable_label(chosen.is_some_and(|c| c.id == m.id), m.name.as_str()); + register(ui, format!("lensBlurFocus:mask:{}", m.id), item.rect); + if item.clicked() { + pick = Some(Some(m.id)); + } + } + }); + if let Some(focus) = pick { + // "Subject": the estimate again, not a fixed shape + let mut patch = json!({"focus_mask": focus}); + if focus.is_none() { + patch["shape"] = json!("auto"); + } + let _ = app.run("develop.merge", json!({"settings": {"lens_blur": patch}, "label": "Lens Blur Focus"})); + } + }); + // without a focus mask: the focus area's shape (the estimate, or a fixed one), which can + // be moved and resized on the photo + if !has_mask { + ui.add_space(4.0); + focus_shape_row(app, ui, b); + if b.shape != lightcraft_develop::FocusShape::Auto { + let active = app.ui.tool == "lensBlurShape"; + let r = text_button(ui, "lensBlurEditShape", "Move & Resize", active).on_hover_text(crate::i18n::tr( + "Drag the shape on the photo to move it; drag its side or bottom handle to stretch it. A shape button puts it back in the middle.", + )); + if r.clicked() { + if active { app.ui.tool.clear() } else { app.ui.tool = "lensBlurShape".into() } + } + } + } + // the AI subject search runs only when asked: it takes a while on the CPU, and the + // automatic estimate stays until then + ui.add_space(4.0); + let r = text_button(ui, "lensBlurFindSubject", "Find Subject", false).on_hover_text(crate::i18n::tr( + "Select the subject with AI (SAM 3), starting where the camera focused when the file records it, and keep it in focus", + )); + if r.clicked() { + match app.run_typed("lensBlur.findSubject", json!({})) { + Ok(v) => { + let msg = if v["from"] == "camera" { "Selecting the subject at the camera's focus point…" } else { "Selecting the subject…" }; + app.toast(ui.ctx(), crate::i18n::tr(msg)); + } + Err(e) => app.ai_error(ui.ctx(), e, None), + } + } + // a Find Subject still running: say so (meanwhile the automatic estimate stays in focus) + let waiting = b.focus_mask.and_then(|id| d.masks.iter().find(|m| m.id == id)).is_some_and(lightcraft_pipeline::lens_blur::awaiting_ai); + if waiting && app.session.segmenter.busy() { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new(crate::i18n::tr("Finding the subject… The first time on a photo takes a while.")).size(11.0).color(t.text)); + }); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(250)); + } else if waiting { + ui.label( + RichText::new(crate::i18n::tr("The subject wasn't selected. Try Find Subject again, or click it in Masking (M).")) + .size(11.0) + .color(t.text), + ); + } + ui.label( + RichText::new(crate::i18n::tr("Refine it in Masking (M): + → Object adds what was missed, − → Object leaves a part out.")) + .size(11.0) + .color(t.text_dim), + ); + })); + lens_blur_brush(app, ui, b); +} + +/// The Lens Blur focus area's shape when no mask sets it: one button per shape, each showing it +/// (drawn in the theme's colours, so it reads on the light themes too). +fn focus_shape_row(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_develop::LensBlur) { + use lightcraft_develop::FocusShape; + let t = Tokens::get(ui.ctx()); + let enabled = ui.is_enabled(); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr("Shape")).font(t.font(13.0)).color(t.text_dim)); + ui.add_space(4.0); + for shape in FocusShape::ALL { + let selected = b.shape == shape; + let (r, resp) = ui.allocate_exact_size(vec2(36.0, 28.0), Sense::click()); + let name = serde_json::to_value(shape).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + register(ui, format!("lensBlurShape:{name}"), r); + crate::widgets::announce(&resp, crate::i18n::tr(shape.label())); + let resp = resp.on_hover_text(crate::i18n::tr(match shape { + FocusShape::Auto => "Auto: the photo's subject, estimated", + FocusShape::Round => "Round", + FocusShape::Wide => "Horizontal oval", + FocusShape::Tall => "Vertical oval", + })); + let p = ui.painter(); + let fill = if selected { + t.pressed + } else if resp.hovered() && enabled { + t.hover + } else { + t.button + }; + p.rect_filled(r, 4.0, fill); + if selected { + p.rect_stroke(r, 4.0, Stroke::new(1.0, if enabled { t.accent } else { t.text_dim }), egui::StrokeKind::Inside); + } + // (dimmed while the blur is off, in the theme's colours) + let colour = match (enabled, selected) { + (false, _) => t.icon.gamma_multiply(0.4), + (true, true) => t.accent, + (true, false) => t.icon, + }; + paint_focus_shape(p, r.shrink(5.0), shape, colour); + // a preset puts the shape back in the middle at its size (also the one in use, once moved) + if resp.clicked() && (!selected || b.area.is_some()) { + let _ = app.run("develop.merge", json!({"settings": {"lens_blur": {"shape": name, "area": null}}, "label": "Lens Blur Shape"})); + } + } + }); +} + +/// A focus shape's picture in `r`: the oval in its proportions (the largest fills the height), or +/// the subject icon for Auto. +fn paint_focus_shape(p: &egui::Painter, r: Rect, shape: lightcraft_develop::FocusShape, colour: Color32) { + let Some((rx, ry)) = shape.radii() else { + paint(p, r, Icon::Subject, colour); + return; + }; + // the shapes' largest radius spans the box's height (all shapes on one scale) + let k = r.height() / 2.0 / 0.32; + let (ax, ay) = (rx as f32 * k, ry as f32 * k); + let c = r.center(); + let pts: Vec = (0..48) + .map(|i| { + let a = i as f32 / 48.0 * std::f32::consts::TAU; + pos2(c.x + ax * a.cos(), c.y + ay * a.sin()) + }) + .collect(); + p.add(egui::Shape::convex_polygon(pts.clone(), colour.gamma_multiply(0.3), Stroke::NONE)); + p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, colour))); +} + +/// Lens Blur Brush Refinement: the Focus and Blur brushes (painted on the photo, see +/// `detail::lens_blur_brush_overlay`), their Amount, the shared brush's Size, Feather, Flow and +/// Auto Mask, and Reset. +fn lens_blur_brush(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_develop::LensBlur) { + let on = b.enabled; + sub_title(ui, crate::i18n::tr("Brush Refinement")); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 4 }).show(ui, |ui| { + ui.add_enabled_ui(on, |ui| { + ui.horizontal(|ui| { + for (tool, label, tip) in + [("lensBlurFocus", "Focus", "Paint what should stay sharp"), ("lensBlurBlur", "Blur", "Paint what should be blurred")] + { + let active = app.ui.tool == tool; + let r = text_button(ui, tool, label, active).on_hover_text(crate::i18n::tr(tip)); + if r.clicked() { + if active { app.ui.tool.clear() } else { app.ui.tool = tool.to_string() } + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let painted = !b.focus_strokes.is_empty() || !b.blur_strokes.is_empty(); + if text_button(ui, "lensBlurBrushReset", "Reset", false) + .on_hover_text(crate::i18n::tr("Remove the strokes of both brushes")) + .clicked() + && painted + { + let _ = app.run("lensBlur.brushReset", json!({})); + } + }); + }); + let mut auto = app.ui.brush_auto_mask; + if ui + .checkbox(&mut auto, crate::i18n::tr("Auto Mask")) + .on_hover_text(crate::i18n::tr("Paint only areas like the one under the brush")) + .changed() + { + app.ui.brush_auto_mask = auto; + } + }); + }); + // the brush is the Masking brush's (its size also follows [ and ]), with its own Amount + // (a reset puts the brush's Amount back to full strength, the others to their minimum) + for (id, label, min, max, get) in [ + ("ui.lensBlurBrushAmount", "Amount", 1.0, 100.0, app.ui.lens_blur_brush_amount as f64), + ("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64), + ("ui.brushFeather", "Feather", 0.0, 100.0, app.ui.brush_feather as f64), + ("ui.brushFlow", "Flow", 1.0, 100.0, app.ui.brush_flow as f64), + ] { + let default = if id == "ui.lensBlurBrushAmount" { 100.0 } else { min }; + let s = ControlSpec { id, label, section: Section::LensBlur, min, max, default, step: 1.0, decimals: 0, track: Track::Plain }; + let out = slider(ui, &s, get, on, None); + if let Some(v) = out.value { + match id { + "ui.lensBlurBrushAmount" => app.ui.lens_blur_brush_amount = v as f32, + "ui.brushSize" => app.ui.brush_size = (v / 400.0) as f32, + "ui.brushFeather" => app.ui.brush_feather = v as f32, + _ => app.ui.brush_flow = v as f32, + } + } + } + // the brushes paint only while the blur is on + if !on && app.ui.tool.starts_with("lensBlur") { + app.ui.tool.clear(); + } +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index ce28482c46..d833fbeb11 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -35,6 +35,7 @@ pub const LOCAL: &[ControlSpec] = &[ spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered), spec("moire", "Moiré", -100.0, 100.0, 1.0, 0, Track::Centered), spec("defringe", "Defringe", -100.0, 100.0, 1.0, 0, Track::Centered), + spec("lens_blur", "Lens Blur", 0.0, 100.0, 1.0, 0, Track::Plain), ]; pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 { @@ -383,7 +384,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } divider(ui); super::mask_effects::effect_row(app, ui, &m); - for s in LOCAL { + // (Lens Blur has its own block below) + for s in LOCAL.iter().filter(|s| s.id != "lens_blur") { let v = local_get(&m.adjust, s.id); let out = slider(ui, s, v, true, None); apply_slider_out(app, s, out, |app, v| app.run("mask.adjust", json!({"values": {s.id: v}}))); @@ -414,10 +416,63 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }; let out = slider(ui, &refine, m.refine, true, None); apply_slider_out(app, &refine, out, |app, v| app.run("mask.refine", json!({"value": v}))); + divider(ui); + lens_blur_block(app, ui, &m); ui.add_space(30.0); let _ = Stroke::NONE; } +/// The mask's Lens Blur block (`section:maskLensBlur`): a header that folds it and whose eye +/// switches it off (the values kept), which side of the mask it blurs (`button:maskLensBlurSide-*`), +/// then its Amount, Boost and Transition (`lens_blur*` local adjustments, `mask.adjust`). +fn lens_blur_block(app: &mut LightcraftApp, ui: &mut egui::Ui, m: &lightcraft_develop::Mask) { + let a = &m.adjust; + let open = !app.ui.section_folded("maskLensBlur"); + let (resp, toggled) = crate::widgets::section_header(ui, "maskLensBlur", "Lens Blur", open, Some(!a.lens_blur_off)); + if let Some(on) = toggled { + // switched on with nothing to blur: a visible amount, as the Edit section's Apply does + let mut v = json!({"lens_blur_off": !on}); + if on && a.lens_blur <= 0.0 { + v["lens_blur"] = json!(50.0); + } + let _ = app.run("mask.adjust", json!({"values": v})); + } else if resp.clicked() { + app.ui.toggle_folded("maskLensBlur"); + } + if !open { + return; + } + ui.add_enabled_ui(!a.lens_blur_off, |ui| { + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 6 }).show(ui, |ui| { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(crate::i18n::tr("Blur")).color(t.text_dim).size(11.5)); + let items = [("Selection", "inside"), ("Outside", "outside")]; + let side = if a.lens_blur_outside { 1 } else { 0 }; + if let Some(i) = crate::widgets::segmented(ui, "maskLensBlurSide", &items, Some(side), 2) + && i != side + { + let mut v = json!({"lens_blur_outside": i == 1}); + if a.lens_blur <= 0.0 { + v["lens_blur"] = json!(50.0); + } + let _ = app.run("mask.adjust", json!({"values": v})); + } + }); + for s in LENS_BLUR { + let v = local_get(a, s.id); + let out = slider(ui, s, v, true, None); + apply_slider_out(app, s, out, |app, v| app.run("mask.adjust", json!({"values": {s.id: v}}))); + } + }); +} + +/// The sliders of a mask's Lens Blur block (key = `LocalAdjustments` field name). +const LENS_BLUR: &[ControlSpec] = &[ + spec("lens_blur", "Amount", 0.0, 100.0, 1.0, 0, Track::Plain), + spec("lens_blur_boost", "Boost", 0.0, 100.0, 1.0, 0, Track::Plain), + spec("lens_blur_transition", "Transition", 0.0, 100.0, 1.0, 0, Track::Plain), +]; + /// Translate fixed action labels, but never mutate or translate user-entered mask names. fn menu_label(source: &str) -> &str { crate::i18n::tr(source) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 37a8c2d4bb..203ede571e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -246,7 +246,8 @@ pub struct UiState { /// the photo that applies to: not an edit, and forgotten on moving to another photo or quitting. #[serde(skip)] pub grading_hidden: Option<(lightcraft_catalog::PhotoId, [bool; 3])>, - /// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove". + /// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove", + /// "lensBlurFocus" / "lensBlurBlur" (Lens Blur Brush Refinement, in the Edit panel). pub tool: String, /// Guided Upright: the selected guide (photo id + index) while the tool is open. A Delete / /// Backspace on it removes that guide alone; a click away or leaving the tool clears it. @@ -260,6 +261,9 @@ pub struct UiState { pub brush_auto_mask: bool, /// Brush Pen Pressure: a pen's pressure scales each dab's size and flow (a mouse paints at full pressure). pub brush_pressure: bool, + /// Lens Blur Brush Refinement: how far a stroke refines the focus, 1..100 (size, feather, + /// flow, Auto Mask and pressure are the brush's above). + pub lens_blur_brush_amount: f32, /// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100). pub remove_size: f32, pub remove_feather: f32, @@ -484,6 +488,7 @@ impl Default for UiState { brush_erase: false, brush_auto_mask: false, brush_pressure: true, + lens_blur_brush_amount: 100.0, remove_size: 0.02, remove_feather: 50.0, remove_opacity: 100.0, diff --git a/crates/ui-egui/src/tests/masking.rs b/crates/ui-egui/src/tests/masking.rs index f7fbc60536..6bf491c37d 100644 --- a/crates/ui-egui/src/tests/masking.rs +++ b/crates/ui-egui/src/tests/masking.rs @@ -2155,3 +2155,157 @@ fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (0.7, 25.0, 0.0, 50.0)); assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "Lift"); } + +/// Lens Blur Brush Refinement: Focus / Blur pick a brush that paints on the photo from the Edit +/// panel (one `lensBlur.brushStroke` per drag, with the brush's settings); ⌥ erases; Reset clears. +#[test] +fn lens_blur_focus_and_blur_brushes_paint_on_the_photo() { + // (`panel.edit` toggles the panel, which a session opens on: set it instead) + let mut h = detail("panel.masking"); + // tall enough for the Lens Blur section under the closed sections + assert_eq!(h.request("ui.resize", json!({"width": 1200, "height": 1400}), T)["ok"], true); + let r = h.request("ui.set", json!({"right": "edit", "openSections": ["lensBlur"], "histogram": false}), T); + assert_eq!(r["ok"], true, "{r}"); + // off: the brushes don't paint + click(&mut h, "button:lensBlurFocus"); + assert_eq!(h.app.ui.tool, "", "no brush while the blur is off"); + click(&mut h, "check:lensBlur.enabled"); + assert!(develop(&h).lens_blur.enabled); + click(&mut h, "button:lensBlurFocus"); + assert_eq!(h.app.ui.tool, "lensBlurFocus"); + h.app.ui.lens_blur_brush_amount = 60.0; + pointer( + &mut h, + json!([{"kind": "down", "x": 0.3, "y": 0.5}, {"kind": "drag", "x": 0.4, "y": 0.5}, {"kind": "drag", "x": 0.5, "y": 0.5}, {"kind": "up", "x": 0.5, "y": 0.5}]), + ); + let b = develop(&h).lens_blur; + assert_eq!(b.focus_strokes.len(), 1, "{b:?}"); + assert!(b.focus_strokes[0].points.len() >= 2 && b.focus_strokes[0].density == 60.0 && b.blur_strokes.is_empty()); + assert!((b.focus_strokes[0].size - h.app.ui.brush_size as f64).abs() < 1e-6, "the shared brush's size"); + click(&mut h, "button:lensBlurBlur"); + assert_eq!(h.app.ui.tool, "lensBlurBlur"); + brush_at(&mut h, 0.6, 0.4); + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.7, "y": 0.4}, {"kind": "up", "x": 0.7, "y": 0.4}], "alt": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let b = develop(&h).lens_blur; + assert_eq!(b.blur_strokes.len(), 2); + assert!(!b.blur_strokes[0].erase && b.blur_strokes[1].erase, "⌥ erases"); + click(&mut h, "button:lensBlurBrushReset"); + let b = develop(&h).lens_blur; + assert!(b.focus_strokes.is_empty() && b.blur_strokes.is_empty()); + // switching the blur off puts the brush down + click(&mut h, "check:lensBlur.enabled"); + assert_eq!(h.app.ui.tool, ""); + h.settle(SETTLE); +} + +/// Lens Blur focus shape: without a focus mask a button per shape sets it; with one they're gone. +#[test] +fn lens_blur_shape_buttons_pick_the_focus_shape() { + use lightcraft_develop::FocusShape; + let mut h = detail("panel.masking"); + assert_eq!(h.request("ui.resize", json!({"width": 1200, "height": 1400}), T)["ok"], true); + let r = h.request("ui.set", json!({"right": "edit", "openSections": ["lensBlur"], "histogram": false}), T); + assert_eq!(r["ok"], true, "{r}"); + click(&mut h, "check:lensBlur.enabled"); + assert_eq!(develop(&h).lens_blur.shape, FocusShape::Auto); + click(&mut h, "lensBlurShape:wide"); + assert_eq!(develop(&h).lens_blur.shape, FocusShape::Wide); + click(&mut h, "lensBlurShape:tall"); + assert_eq!(develop(&h).lens_blur.shape, FocusShape::Tall); + click(&mut h, "lensBlurShape:auto"); + assert_eq!(develop(&h).lens_blur.shape, FocusShape::Auto); + // a focus mask: the shape is the mask's, no buttons + exec(&mut h, "mask.add", json!({"kind": "radial"})); + let id = develop(&h).masks[0].id; + exec(&mut h, "develop.merge", json!({"settings": {"lens_blur": {"focus_mask": id}}})); + let r = h.request("ui.set", json!({"right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "lensBlurShape:round"}), T); + assert_eq!(r["ok"], false, "no shape buttons with a focus mask: {r}"); + h.settle(SETTLE); +} + +/// While Apply is off the focus controls don't respond; on, Move & Resize drags the fixed shape on +/// the photo (one undo step per drag) and a shape button puts it back in the middle. +#[test] +fn lens_blur_focus_controls_follow_apply_and_the_shape_moves() { + use lightcraft_develop::FocusShape; + let mut h = detail("panel.masking"); + assert_eq!(h.request("ui.resize", json!({"width": 1200, "height": 1400}), T)["ok"], true); + let r = h.request("ui.set", json!({"right": "edit", "openSections": ["lensBlur"], "histogram": false}), T); + assert_eq!(r["ok"], true, "{r}"); + // off: shape buttons, Find Subject and In Focus are inert + let before = develop(&h).lens_blur; + click(&mut h, "lensBlurShape:wide"); + click(&mut h, "button:lensBlurFindSubject"); + click(&mut h, "dropdown:lensBlurFocus"); + let r = h.request("ui.clickWidget", json!({"id": "lensBlurFocus:subject"}), T); + assert_eq!(r["ok"], false, "the In Focus menu doesn't open: {r}"); + assert_eq!(develop(&h).lens_blur, before, "nothing changes while Apply is off"); + // on: a round shape, then Move & Resize + click(&mut h, "check:lensBlur.enabled"); + click(&mut h, "lensBlurShape:round"); + assert_eq!(develop(&h).lens_blur.shape, FocusShape::Round); + click(&mut h, "button:lensBlurEditShape"); + assert_eq!(h.app.ui.tool, "lensBlurShape"); + // drag the middle of the photo (the shape's centre) to the right + pointer( + &mut h, + json!([{"kind": "down", "x": 0.5, "y": 0.5}, {"kind": "drag", "x": 0.55, "y": 0.5}, {"kind": "drag", "x": 0.6, "y": 0.5}, {"kind": "up", "x": 0.6, "y": 0.5}]), + ); + let a = develop(&h).lens_blur.area.expect("moved"); + assert!(a.center.x > 0.57 && (a.center.y - 0.5).abs() < 0.02, "{a:?}"); + assert!((a.rx - 0.22).abs() < 1e-9 && (a.ry - 0.22).abs() < 1e-9, "same size"); + // one drag, one undo step + exec(&mut h, "edit.undo", json!({})); + assert!(develop(&h).lens_blur.area.is_none(), "{:?}", develop(&h).lens_blur.area); + exec(&mut h, "edit.redo", json!({})); + assert!(develop(&h).lens_blur.area.is_some()); + // the preset button puts it back in the middle + click(&mut h, "lensBlurShape:round"); + assert!(develop(&h).lens_blur.area.is_none()); + // switching the blur off puts the tool down + click(&mut h, "check:lensBlur.enabled"); + assert_eq!(h.app.ui.tool, ""); + h.settle(SETTLE); +} + +/// A mask's Lens Blur is a block of its own under the mask's sliders: which side it blurs, its +/// sliders, an eye that switches it off (its controls then inert) and a header that folds it. +#[test] +fn a_masks_lens_blur_block_picks_the_side_and_switches_off() { + let mut h = detail("panel.masking"); + assert_eq!(h.request("ui.resize", json!({"width": 1200, "height": 3200}), T)["ok"], true); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); + h.settle(SETTLE); + let adjust = |h: &Headless| develop(h).masks[0].adjust; + // the block's own sliders, not one more in the list + assert!(h.app.widgets.iter().any(|(id, _)| id == "section:maskLensBlur")); + assert!(h.app.widgets.iter().any(|(id, _)| id == "slider:lens_blur_transition")); + // blurring the outside starts with a visible amount + click(&mut h, "button:maskLensBlurSide-outside"); + let a = adjust(&h); + assert!(a.lens_blur_outside && a.lens_blur == 50.0, "{a:?}"); + let r = h.request("ui.clickWidget", json!({"id": "slider:lens_blur_boost", "fx": 0.6}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(adjust(&h).lens_blur_boost > 30.0, "{:?}", adjust(&h)); + // the eye (shown over the header) switches it off and keeps the values + h.request("ui.hoverWidget", json!({"id": "section:maskLensBlur"}), T); + h.settle(SETTLE); + click(&mut h, "sectionEye:maskLensBlur"); + let off = adjust(&h); + assert!(off.lens_blur_off && off.lens_blur == 50.0 && off.lens_blur_outside, "{off:?}"); + // off: its controls don't respond + click(&mut h, "button:maskLensBlurSide-inside"); + assert_eq!(adjust(&h), off); + // on again + click(&mut h, "sectionEye:maskLensBlur"); + assert!(!adjust(&h).lens_blur_off); + // the header folds it + click(&mut h, "section:maskLensBlur"); + assert!(h.app.ui.section_folded("maskLensBlur")); + click(&mut h, "section:maskLensBlur"); + assert!(!h.app.ui.section_folded("maskLensBlur")); + h.settle(SETTLE); +} diff --git a/docs/ai-masks.md b/docs/ai-masks.md index 41873cb5a6..c24ea64cb3 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -6,8 +6,13 @@ through Metal on macOS, on the CPU elsewhere (for now). If the Mac's GPU can't r the CPU (see the worker below). **The model is optional.** It is not part of LightCraft, and nothing requires it: without it, -Object and Describe offer to download it, every other feature (and every existing mask, see -below) works, and no render, export or command ever waits for it. +Object, Describe and Lens Blur's **Find Subject** offer to download it, every other feature (and +every existing mask, see below) works, and no render, export or command ever waits for it. + +Lens Blur reuses this same optional SAM 3 model for **Find Subject**; it does not add or download a +separate model. The automatic subject estimate used before Find Subject finishes (or when **Auto** +is selected) uses existing person mattes when available, otherwise a local saliency heuristic, so +that fallback does not require SAM 3. ## Using them @@ -74,7 +79,9 @@ ssh -N -T -o ExitOnForwardFailure=yes -L 127.0.0.1:8793:127.0.0.1:8793 user@your LIGHTCRAFT_SAM3_REMOTE=127.0.0.1:8793 lightcraft ``` -Object clicks, Describe prompts, and detail refinement use the remote worker. Only the +Object clicks, Describe prompts, detail refinement and Lens Blur's Find Subject (people and animals, +from the camera's focus point when the file records it) use the remote worker; a worker older than the +app rejects a request it doesn't know with an error, so update both together. Only the rendered model input (long edge at most 1008 pixels) and prompts cross the tunnel. The worker caches the current photo; detail crops do not replace that cache. Returned masks stay in the local library, so later adjustments and exports work without the connection. @@ -96,9 +103,9 @@ not RAW development, denoising, or upscaling. The model licence below still appl ### In the app (recommended) -The first time you pick Object or Describe without the model, LightCraft asks: +The first time you pick Object, Describe or Lens Blur's Find Subject without the model, LightCraft asks: -> **Download the SAM 3 model?** Object and Describe masks use SAM 3, Meta's segmentation model. +> **Download the SAM 3 model?** Object, Describe and Lens Blur's Find Subject use SAM 3, Meta's segmentation model. > It isn't part of LightCraft, and everything else works without it. Download it once (about > 3.4 GB) · *folder*. Licence: SAM License (Meta) — Meta's terms, not LightCraft's. Downloading > it means accepting them. [Read the SAM License] — **Not Now** / **Download** diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0666d11e95..2b9d779822 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -31,7 +31,7 @@ cited here still exists. Formerly docs/parity.md. | C. Views & navigation (VIEW) | 16 | 1 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 42 | 5 | 3 | 1 | 25/28 (89%) | 14/15 (93%) | +| F. Edit panel — global adjustments (EDIT) | 42 | 6 | 2 | 1 | 25/28 (89%) | 14/15 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 10 | 1 | 0 | 1 | 8/8 (100%) | 2/3 (67%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | @@ -53,9 +53,9 @@ cited here still exists. Formerly docs/parity.md. | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 77 | 5 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 41 | 26 | 21 | 8 | — | 24/27 (89%) | -| **Total** | 419 | 70 | 57 | 35 | 193/203 (95%) | 147/163 (90%) | +| **Total** | 419 | 71 | 56 | 35 | 193/203 (95%) | 147/163 (90%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.2%** of 546 in-scope rows — P0 97.5% of 203 · P1 94.2% of 163 · P2 56.7% of 179. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.2%** of 546 in-scope rows — P0 97.5% of 203 · P1 94.2% of 163 · P2 57.0% of 179. ## Top gaps and shortcut notes @@ -222,7 +222,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | | LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests/constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | -| LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | +| LR-EDIT-LENSBLUR | Lens blur | P2 | 🟡 | `ctl:lensBlur.amount`, `ctl:lensBlur.boost`, `ctl:lensBlur.transition`, `cmd:section.lensBlur`, `cmd:lensBlur.brushStroke`, `cmd:lensBlur.brushReset`, `cmd:lensBlur.findSubject`, `crates/pipeline/src/lens_blur.rs`, `crates/ui-egui/src/panels/edit.rs` (`lens_blur`) | Lens Blur section: Apply, Blur Amount, Boost (bokeh highlights), what stays in focus (the subject: person mattes, else the saliency heuristic; or any mask, e.g. an SAM 3 Object mask; Find Subject makes one, clicked where the camera focused when the file records it, else at the likeliest subject, only when asked) and Brush Refinement (Focus / Blur brushes with Amount, Size, Feather, Flow, Auto Mask, ⌥ erases, Reset). Transition: the blur grows with the distance from the focus (a stand-in for depth), not a cut-out edge. Without a focus mask, Shape picks the focus area: Auto (the subject estimate), Round, Horizontal or Vertical oval, centred in the crop, and Move & Resize drags it on the photo (centre pin, side and bottom handles; one undo step per drag) (beyond Lightroom). The focus controls are disabled while Apply is off. Disc (circle) bokeh without halos, CPU for both renderers, zoomed windows match the whole render. Missing: an AI depth map (so Focal Range, the focus picker and Visualize Depth), bokeh shapes, crs XMP mapping | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | | LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) | | LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `cmd:section.calibration`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests/solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.` `{solo: true}`, refused for a section the Edit panel has not got; header clicks run `section.` too, Calibration included). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | @@ -308,7 +308,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | -| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs`, `crates/pipeline/src/lens_blur.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve. Beyond Lightroom: a Lens Blur block in every mask (`crates/ui-egui/src/panels/masking.rs`, `lens_blur_block`): blur the selection or what it leaves out, Amount, Boost, Transition (the blur grows away from what stays sharp), an eye to switch it off; disc bokeh, no halo | | LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until O / the eye shows it again or a mask is created, picked, duplicated or added to (issue #1800); shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests/preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | @@ -645,7 +645,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete`, `cmd:album.removePhotos` | in a normal album ⌫ removes from the album only (`album.removePhotos`, issue #1805); in a smart album it does nothing; elsewhere it deletes (to Recently Deleted) | | KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | | KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | -| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 | +| KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics`, `cmd:section.lensBlur` | ⌘1–⌘6 (Light, Color, Effects, Detail, Optics, Lens Blur) as observed; our Edit panel has no Geometry section for ⌘7; Zoom 100% moved to ⌥⌘0 | | KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | Shift+P opens Presets outside the two library grids; in grids it picks and advances (Classic convention). Menu hides the Presets key there; shortcut help follows the view | | KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit | | KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ✅ | `cmd:brush.smaller`, `cmd:brush.larger` | Masking brush and Remove tool (and its selected spot) | @@ -803,6 +803,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-11-07 | correction | Compressed ORF sensor support withdrawn pending provenance review; embedded preview fallback remains. Coverage regenerated without the removed decoder: 557 verified, 665 unverified, 187 preview-only, 37 unsupported; 94% use-weighted coverage. | +| 2026-11-05 | minor | LR-EDIT-LENSBLUR: ⬜ → 🟡 (Lens Blur section: Apply, Blur Amount, Boost, focus on the subject or a mask; no depth map yet); LR-MASK-SLIDERS: a Lens Blur block in every mask (selection or outside, Amount, Boost, Transition); KEY-SECTIONS: ⌘6 opens Lens Blur | | 2026-11-04 | minor | Compressed ORF support was introduced in #3775; subsequently withdrawn pending provenance review. Earlier clean-room and verification claims are withdrawn; compressed files use embedded previews when available. See [ORF status](orf-compression.md). | | 2026-10-28 | minor | KEY-HIDEPINS ✅ → 🟡: in the Masking panel an unmodified H toggles the mask pins (`view.maskPins`, the View ▸ Show Mask Pins switch) instead of opening Remove; the toggle yields only to an explicit assignment of H itself, a Remove key reassigned to anything else still opens Remove there, and outside Masking — and the full-screen preview, where no panel shows — H keeps its own binding (Remove, by default). Remove's spot pins keep ⇧H as their only hide toggle (`tests/keymap` `h_toggles_the_mask_pins_while_masking`, `a_remapped_remove_key_still_opens_remove_while_masking`, `h_still_toggles_the_pins_when_remove_moves_to_another_key`) | | 2026-11-07 | minor | LR-EDIT-EFFECTS-GRAIN, LRC-DEV-PROCESS: process V7 takes film grain to crystal layers ([grain.md](grain.md)); V1 to V6 keep value noise | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e38aeec76a..e3e8a26665 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -95,7 +95,7 @@ search embeddings; generative remove runs in Adobe's cloud. | Subject / Sky / Background masks | classical heuristics; the file's own DNG semantic mattes | 🟡 | | People (faces, body, hair, skin…) and Landscape masks | — | ⬜ | | Object / Describe masks | SAM 3 in pure Rust (Metal / CPU); opt-in 3.4 GB download, no mirror configured | 🟡 | -| Depth range mask, Lens Blur | — | ⬜ | +| Depth range mask, Lens Blur | Lens Blur keeps the subject (or any mask, e.g. an Object mask) sharp and blurs the rest with disc bokeh and Boost; no depth map yet, so no focal range or depth range mask | 🟡 | | AI Denoise | Bayer only, built-in classical denoiser out of the box, optional user-installed weights, pure-Rust CPU / GPU | 🟡 | | Raw Details, Super Resolution | — | ⬜ | | Generative remove, distractions, reflections | — (generative is out of scope: cloud) | ⬜ | @@ -300,6 +300,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| | 2026-11-07 | correction | Compressed ORF sensor support withdrawn pending provenance review; embedded preview fallback remains. Coverage regenerated without the removed decoder: 557 verified, 665 unverified, 187 preview-only, 37 unsupported; 94% use-weighted coverage. | +| 2026-11-05 | minor | AI table: Lens Blur ⬜ → 🟡 (subject- or mask-focused disc blur, no depth map); headline numbers unchanged | | 2026-11-04 | minor | Compressed ORF support was introduced in #3775; subsequently withdrawn pending provenance review. Earlier clean-room and verification claims are withdrawn; compressed files use embedded previews when available. See [ORF status](orf-compression.md). | | 2026-10-24 | patch | Stability, the stability discount, the Beta-needs list and the estimate history: tests run in CI on Linux x64 since #2667 (FreeBSD nightly), not on macOS or Windows (gap 9); percentages and hours unchanged | | 2026-10-23 | minor | AI Denoise runs without a model (built-in denoiser); percentages unchanged, since it is a classical filter and Bayer-only |