From 9c4526129e8a6192c0342dde71d5271dd75fa4cb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 11:16:19 -0400 Subject: [PATCH] LR-PROF-CREATIVE: 16 own creative profiles (Film, Cinematic, Muted, B&W) as amount-scaled settings deltas Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/presets.rs | 61 +++++++ crates/pipeline/src/lib.rs | 2 +- crates/pipeline/src/profiles.rs | 291 +++++++++++++++++++++++++++++--- crates/ui-egui/src/lib.rs | 2 +- docs/parity.md | 6 +- 5 files changed, 331 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index f7994f061..a9c06086b 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -263,8 +263,40 @@ pub const PROFILES: &[ProfileInfo] = &[ ProfileInfo { id: "lc.landscape", name: "Landscape", group: "Basic" }, ProfileInfo { id: "lc.portrait", name: "Portrait", group: "Basic" }, ProfileInfo { id: "lc.mono", name: "Monochrome", group: "Basic" }, + ProfileInfo { id: "lc.film.warm-print", name: "Warm Print", group: "Film" }, + ProfileInfo { id: "lc.film.cool-fade", name: "Cool Fade", group: "Film" }, + ProfileInfo { id: "lc.film.golden-hour", name: "Golden Hour", group: "Film" }, + ProfileInfo { id: "lc.film.faded-slide", name: "Faded Slide", group: "Film" }, + ProfileInfo { id: "lc.cine.teal-amber", name: "Teal & Amber", group: "Cinematic" }, + ProfileInfo { id: "lc.cine.night-blue", name: "Night Blue", group: "Cinematic" }, + ProfileInfo { id: "lc.cine.desert-heat", name: "Desert Heat", group: "Cinematic" }, + ProfileInfo { id: "lc.cine.neon-dusk", name: "Neon Dusk", group: "Cinematic" }, + ProfileInfo { id: "lc.muted.matte-soft", name: "Matte Soft", group: "Muted" }, + ProfileInfo { id: "lc.muted.bleached", name: "Bleached", group: "Muted" }, + ProfileInfo { id: "lc.muted.pastel-haze", name: "Pastel Haze", group: "Muted" }, + ProfileInfo { id: "lc.muted.quiet-green", name: "Quiet Green", group: "Muted" }, + ProfileInfo { id: "lc.bw.mono-rich", name: "Mono Rich", group: "B&W" }, + ProfileInfo { id: "lc.bw.red-filter", name: "Mono Red Filter", group: "B&W" }, + ProfileInfo { id: "lc.bw.soft", name: "Mono Soft", group: "B&W" }, + ProfileInfo { id: "lc.bw.sepia", name: "Sepia Tone", group: "B&W" }, ]; +/// Profile groups in menu/browser order. +pub fn profile_groups() -> Vec<&'static str> { + let mut out: Vec<&'static str> = Vec::new(); + for p in PROFILES { + if !out.contains(&p.group) { + out.push(p.group); + } + } + out +} + +/// A profile by id. +pub fn profile(id: &str) -> Option<&'static ProfileInfo> { + PROFILES.iter().find(|p| p.id == id) +} + #[cfg(test)] mod tests { use super::*; @@ -280,6 +312,35 @@ mod tests { } } + #[test] + fn every_profile_has_a_look_and_ids_are_unique() { + let looks = crate::pipeline::profiles::LOOK_IDS; + let mut ids = std::collections::HashSet::new(); + for p in PROFILES { + assert!(ids.insert(p.id), "{}", p.id); + assert!(p.id == "lc.color" || looks.contains(&p.id), "{} has no look", p.id); + } + assert!(PROFILES.len() >= 22); + assert_eq!(profile_groups(), ["Basic", "Film", "Cinematic", "Muted", "B&W"]); + } + + #[test] + fn creative_profiles_render_without_moving_the_sliders() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap(); + let before = s.develop_of(id).unwrap(); + let plain = s.render_now(id, 96, 96).unwrap().image; + s.execute("develop.profile", &json!({"id": "lc.cine.teal-amber", "amount": 150})).unwrap(); + let after = s.develop_of(id).unwrap(); + assert_eq!((after.profile.id.as_str(), after.profile.amount), ("lc.cine.teal-amber", 150.0)); + let mut same = (*after).clone(); + same.profile = before.profile.clone(); + assert_eq!(same, *before, "only the profile changed"); + let looked = s.render_now(id, 96, 96).unwrap().image; + assert_ne!(plain.as_bytes(), looked.as_bytes()); + } + #[test] fn slugs() { assert_eq!(slug("Warm & Soft!"), "warm-soft"); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 4ba0d886f..9519be1f3 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -348,7 +348,7 @@ pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { } pub(crate) fn is_bw(s: &DevelopSettings) -> bool { - s.treatment == Treatment::Bw || s.profile.id == "lc.mono" + s.treatment == Treatment::Bw || s.profile.id == "lc.mono" || s.profile.id.starts_with("lc.bw.") } /// `LIGHTCRAFT_PROFILE` is set: print per-stage timings to stderr. diff --git a/crates/pipeline/src/profiles.rs b/crates/pipeline/src/profiles.rs index ff95eed6a..c519b2a34 100644 --- a/crates/pipeline/src/profiles.rs +++ b/crates/pipeline/src/profiles.rs @@ -1,9 +1,45 @@ //! Base rendering profiles. A profile is a look applied *underneath* the user's sliders (the sliders //! stay at their displayed values); `profile.amount` (0–200 %) scales it. All looks are our own. +//! +//! Every look is a set of *deltas* on the user's settings, each multiplied by the amount: slider +//! offsets (contrast, saturation, colour mixer, B&W mix…), colour-grading wheels (added as colour +//! vectors to the user's wheels) and point-curve fades (black/white output levels composed after +//! the user's point curves). The CPU pipeline and the GPU path both render [`effective`] +//! settings, so a profile needs no kernel of its own. No look adds grain. use std::borrow::Cow; -use lightcraft_develop::{DevelopSettings, Treatment}; +use lightcraft_develop::{DevelopSettings, Treatment, Wheel}; +use lightcraft_geom::Point; + +/// Add a colour-grading wheel (`hue` °, `sat` 0..100, `lum` −100..100) scaled by `k` to `w`, as a +/// colour vector (so a profile's tint combines with the user's instead of replacing it). +fn wheel(w: &mut Wheel, hue: f64, sat: f64, lum: f64, k: f64) { + let (ua, ub) = (w.sat * w.hue.to_radians().cos(), w.sat * w.hue.to_radians().sin()); + let (pa, pb) = (sat * k * hue.to_radians().cos(), sat * k * hue.to_radians().sin()); + let (a, b) = (ua + pa, ub + pb); + let s = a.hypot(b); + if s > 1e-9 { + w.hue = b.atan2(a).to_degrees().rem_euclid(360.0); + } else if w.sat == 0.0 { + w.hue = hue; + } + w.sat = s.clamp(0.0, 100.0); + w.lum = (w.lum + lum * k).clamp(-100.0, 100.0); +} + +/// Fade a point curve: output black level `black` and white level `white` (0..1, moved from the +/// identity by `k`), composed after the user's curve (`y → b + y·(w − b)`). +fn fade(pts: &mut Vec, black: f64, white: f64, k: f64) { + let b = (black * k).clamp(0.0, 0.45); + let w = (1.0 - (1.0 - white) * k).clamp(0.55, 1.0); + if pts.len() < 2 { + *pts = vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)]; + } + for p in pts.iter_mut() { + p.y = b + p.y.clamp(0.0, 1.0) * (w - b); + } +} /// The settings the pipeline actually renders: user settings plus the profile's look. pub fn effective(s: &DevelopSettings) -> Cow<'_, DevelopSettings> { @@ -13,45 +49,199 @@ pub fn effective(s: &DevelopSettings) -> Cow<'_, DevelopSettings> { return Cow::Borrowed(s); } let mut e = s.clone(); - let add = |v: &mut f64, d: f64, lo: f64, hi: f64| *v = (*v + d * k).clamp(lo, hi); + let add = |v: &mut f64, d: f64| *v = (*v + d * k).clamp(-100.0, 100.0); match id { + // ---- Basic "lc.neutral" => { - add(&mut e.light.contrast, -18.0, -100.0, 100.0); - add(&mut e.color.saturation, -10.0, -100.0, 100.0); - add(&mut e.light.highlights, -10.0, -100.0, 100.0); - add(&mut e.light.shadows, 8.0, -100.0, 100.0); + add(&mut e.light.contrast, -18.0); + add(&mut e.color.saturation, -10.0); + add(&mut e.light.highlights, -10.0); + add(&mut e.light.shadows, 8.0); } "lc.vivid" => { - add(&mut e.light.contrast, 16.0, -100.0, 100.0); - add(&mut e.color.saturation, 14.0, -100.0, 100.0); - add(&mut e.color.vibrance, 14.0, -100.0, 100.0); - add(&mut e.light.blacks, -6.0, -100.0, 100.0); + add(&mut e.light.contrast, 16.0); + add(&mut e.color.saturation, 14.0); + add(&mut e.color.vibrance, 14.0); + add(&mut e.light.blacks, -6.0); } "lc.landscape" => { - add(&mut e.light.contrast, 10.0, -100.0, 100.0); - add(&mut e.color.vibrance, 10.0, -100.0, 100.0); - add(&mut e.mixer.green.sat, 14.0, -100.0, 100.0); - add(&mut e.mixer.aqua.sat, 10.0, -100.0, 100.0); - add(&mut e.mixer.blue.sat, 14.0, -100.0, 100.0); - add(&mut e.mixer.blue.lum, -6.0, -100.0, 100.0); - add(&mut e.effects.clarity, 6.0, -100.0, 100.0); + add(&mut e.light.contrast, 10.0); + add(&mut e.color.vibrance, 10.0); + add(&mut e.mixer.green.sat, 14.0); + add(&mut e.mixer.aqua.sat, 10.0); + add(&mut e.mixer.blue.sat, 14.0); + add(&mut e.mixer.blue.lum, -6.0); + add(&mut e.effects.clarity, 6.0); } "lc.portrait" => { - add(&mut e.light.contrast, -6.0, -100.0, 100.0); - add(&mut e.mixer.orange.sat, -8.0, -100.0, 100.0); - add(&mut e.mixer.orange.lum, 6.0, -100.0, 100.0); - add(&mut e.mixer.red.sat, -5.0, -100.0, 100.0); - add(&mut e.effects.texture, -6.0, -100.0, 100.0); + add(&mut e.light.contrast, -6.0); + add(&mut e.mixer.orange.sat, -8.0); + add(&mut e.mixer.orange.lum, 6.0); + add(&mut e.mixer.red.sat, -5.0); + add(&mut e.effects.texture, -6.0); } "lc.mono" => { e.treatment = Treatment::Bw; - add(&mut e.light.contrast, 8.0, -100.0, 100.0); + add(&mut e.light.contrast, 8.0); + } + // ---- Film + "lc.film.warm-print" => { + add(&mut e.light.contrast, 6.0); + add(&mut e.color.saturation, -4.0); + add(&mut e.mixer.orange.lum, 4.0); + fade(&mut e.curve.master, 0.04, 0.97, k); + wheel(&mut e.grading.highlights, 40.0, 18.0, 0.0, k); + wheel(&mut e.grading.shadows, 25.0, 8.0, 0.0, k); + } + "lc.film.cool-fade" => { + add(&mut e.light.contrast, -8.0); + add(&mut e.color.saturation, -10.0); + fade(&mut e.curve.master, 0.07, 0.95, k); + wheel(&mut e.grading.shadows, 210.0, 16.0, 0.0, k); + wheel(&mut e.grading.highlights, 190.0, 6.0, 0.0, k); + } + "lc.film.golden-hour" => { + add(&mut e.color.vibrance, 10.0); + add(&mut e.curve.highlights, -8.0); + add(&mut e.mixer.orange.sat, 8.0); + add(&mut e.mixer.yellow.hue, -6.0); + wheel(&mut e.grading.global, 45.0, 10.0, 0.0, k); + wheel(&mut e.grading.highlights, 35.0, 20.0, 0.0, k); + } + "lc.film.faded-slide" => { + add(&mut e.light.contrast, 14.0); + add(&mut e.color.vibrance, 8.0); + add(&mut e.color.saturation, 6.0); + add(&mut e.curve.shadows, 10.0); + fade(&mut e.curve.blue, 0.06, 1.0, k); + fade(&mut e.curve.red, 0.0, 0.97, k); + } + // ---- Cinematic + "lc.cine.teal-amber" => { + add(&mut e.light.contrast, 10.0); + add(&mut e.mixer.orange.sat, 6.0); + add(&mut e.mixer.green.hue, 20.0); + add(&mut e.mixer.green.sat, -20.0); + add(&mut e.mixer.blue.hue, -8.0); + wheel(&mut e.grading.shadows, 195.0, 30.0, 0.0, k); + wheel(&mut e.grading.highlights, 38.0, 24.0, 0.0, k); + } + "lc.cine.night-blue" => { + add(&mut e.light.contrast, 12.0); + add(&mut e.color.saturation, -12.0); + add(&mut e.curve.darks, -8.0); + wheel(&mut e.grading.shadows, 220.0, 28.0, -4.0, k); + wheel(&mut e.grading.midtones, 215.0, 10.0, 0.0, k); + } + "lc.cine.desert-heat" => { + add(&mut e.light.contrast, 8.0); + add(&mut e.curve.lights, 6.0); + add(&mut e.mixer.blue.sat, -25.0); + add(&mut e.mixer.aqua.sat, -20.0); + wheel(&mut e.grading.global, 32.0, 16.0, 0.0, k); + wheel(&mut e.grading.highlights, 48.0, 12.0, 0.0, k); + } + "lc.cine.neon-dusk" => { + add(&mut e.light.contrast, 10.0); + add(&mut e.color.vibrance, 18.0); + add(&mut e.mixer.magenta.sat, 12.0); + add(&mut e.mixer.purple.sat, 12.0); + wheel(&mut e.grading.shadows, 265.0, 22.0, 0.0, k); + wheel(&mut e.grading.highlights, 330.0, 14.0, 0.0, k); + } + // ---- Muted + "lc.muted.matte-soft" => { + add(&mut e.light.contrast, -12.0); + add(&mut e.color.saturation, -14.0); + add(&mut e.curve.highlights, -6.0); + fade(&mut e.curve.master, 0.08, 0.96, k); + } + "lc.muted.bleached" => { + add(&mut e.light.contrast, 22.0); + add(&mut e.color.saturation, -38.0); + add(&mut e.curve.lights, 6.0); + add(&mut e.effects.clarity, 8.0); + } + "lc.muted.pastel-haze" => { + add(&mut e.light.contrast, -18.0); + add(&mut e.color.saturation, -20.0); + add(&mut e.color.vibrance, 12.0); + fade(&mut e.curve.master, 0.1, 1.0, k); + wheel(&mut e.grading.highlights, 330.0, 6.0, 0.0, k); + wheel(&mut e.grading.shadows, 200.0, 6.0, 0.0, k); + } + "lc.muted.quiet-green" => { + add(&mut e.light.contrast, -4.0); + add(&mut e.color.saturation, -8.0); + add(&mut e.mixer.green.sat, -30.0); + add(&mut e.mixer.green.hue, -10.0); + add(&mut e.mixer.yellow.sat, -18.0); + add(&mut e.mixer.aqua.sat, -15.0); + fade(&mut e.curve.master, 0.04, 1.0, k); + } + // ---- B&W + "lc.bw.mono-rich" => { + e.treatment = Treatment::Bw; + add(&mut e.light.contrast, 18.0); + add(&mut e.curve.darks, -8.0); + add(&mut e.curve.lights, 6.0); + add(&mut e.bw_mix.red, 8.0); + add(&mut e.bw_mix.orange, 10.0); + add(&mut e.bw_mix.blue, -12.0); + } + "lc.bw.red-filter" => { + e.treatment = Treatment::Bw; + add(&mut e.light.contrast, 12.0); + add(&mut e.bw_mix.red, 40.0); + add(&mut e.bw_mix.orange, 30.0); + add(&mut e.bw_mix.yellow, 10.0); + add(&mut e.bw_mix.green, -20.0); + add(&mut e.bw_mix.aqua, -30.0); + add(&mut e.bw_mix.blue, -45.0); + } + "lc.bw.soft" => { + e.treatment = Treatment::Bw; + add(&mut e.light.contrast, -14.0); + add(&mut e.bw_mix.orange, 12.0); + fade(&mut e.curve.master, 0.06, 0.97, k); + } + "lc.bw.sepia" => { + e.treatment = Treatment::Bw; + add(&mut e.light.contrast, 6.0); + fade(&mut e.curve.master, 0.04, 0.98, k); + wheel(&mut e.grading.shadows, 28.0, 18.0, 0.0, k); + wheel(&mut e.grading.highlights, 45.0, 14.0, 0.0, k); } _ => return Cow::Borrowed(s), } Cow::Owned(e) } +/// Ids of the looks [`effective`] implements (besides the identity `lc.color`). +pub const LOOK_IDS: &[&str] = &[ + "lc.neutral", + "lc.vivid", + "lc.landscape", + "lc.portrait", + "lc.mono", + "lc.film.warm-print", + "lc.film.cool-fade", + "lc.film.golden-hour", + "lc.film.faded-slide", + "lc.cine.teal-amber", + "lc.cine.night-blue", + "lc.cine.desert-heat", + "lc.cine.neon-dusk", + "lc.muted.matte-soft", + "lc.muted.bleached", + "lc.muted.pastel-haze", + "lc.muted.quiet-green", + "lc.bw.mono-rich", + "lc.bw.red-filter", + "lc.bw.soft", + "lc.bw.sepia", +]; + #[cfg(test)] mod tests { use super::*; @@ -60,12 +250,61 @@ mod tests { fn default_profile_is_borrowed_and_others_change_things() { let s = DevelopSettings::default(); assert!(matches!(effective(&s), Cow::Borrowed(_))); - for id in ["lc.neutral", "lc.vivid", "lc.landscape", "lc.portrait", "lc.mono"] { + for id in LOOK_IDS { let mut t = s.clone(); - t.profile.id = id.into(); - assert_ne!(*effective(&t), s, "{id}"); + t.profile.id = (*id).into(); + assert_ne!(*effective(&t), t, "{id}"); t.profile.amount = 0.0; assert_eq!(*effective(&t), t, "{id} at 0%"); } } + + #[test] + fn looks_scale_with_amount_and_never_add_grain() { + let mut s = DevelopSettings::default(); + s.light.contrast = 5.0; + for id in LOOK_IDS { + s.profile.id = (*id).into(); + let at = |a: f64| { + let mut t = s.clone(); + t.profile.amount = a; + effective(&t).into_owned() + }; + let (half, full, double) = (at(50.0), at(100.0), at(200.0)); + // slider deltas are linear in the amount + let d = |e: &DevelopSettings| e.light.contrast - s.light.contrast; + assert!((d(&full) - 2.0 * d(&half)).abs() < 1e-9, "{id}"); + assert!((d(&double) - 2.0 * d(&full)).abs() < 1e-9, "{id}"); + // wheels and point-curve fades grow with the amount + let lift = |e: &DevelopSettings| e.curve.master.first().map_or(0.0, |p| p.y); + for (a, b) in [(&half, &full), (&full, &double)] { + assert!(a.grading.shadows.sat <= b.grading.shadows.sat + 1e-9, "{id}"); + assert!(a.grading.highlights.sat <= b.grading.highlights.sat + 1e-9, "{id}"); + assert!(lift(a) <= lift(b) + 1e-9, "{id}"); + } + if lift(&full) > 0.0 { + assert!((lift(&full) - 2.0 * lift(&half)).abs() < 1e-9, "{id}"); + } + assert_eq!(full.grain, s.grain, "{id} adds no grain"); + assert_eq!(full.profile, s.profile, "{id}"); + } + } + + #[test] + fn wheels_and_fades_compose_with_user_settings() { + let mut s = DevelopSettings::default(); + s.grading.shadows = Wheel { hue: 195.0, sat: 10.0, lum: 0.0 }; + s.curve.master = vec![Point::new(0.0, 0.0), Point::new(0.5, 0.6), Point::new(1.0, 1.0)]; + s.profile.id = "lc.cine.teal-amber".into(); + let e = effective(&s); + assert!((e.grading.shadows.hue - 195.0).abs() < 1e-6 && (e.grading.shadows.sat - 40.0).abs() < 1e-6, "{:?}", e.grading.shadows); + assert_eq!(e.curve.master, s.curve.master, "no fade in this look: the user's curve stays"); + s.profile.id = "lc.muted.matte-soft".into(); + let e = effective(&s); + assert_eq!(e.curve.master.len(), 3, "user points kept"); + let ys: Vec = e.curve.master.iter().map(|p| p.y).collect(); + for (y, want) in ys.iter().zip([0.08, 0.08 + 0.6 * 0.88, 0.96]) { + assert!((y - want).abs() < 1e-9, "{ys:?}"); + } + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fd8f579..f881936d4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -388,5 +388,5 @@ pub fn now_ms() -> f64 { /// Whether the settings render in black & white. pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool { - d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" + d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.") } diff --git a/docs/parity.md b/docs/parity.md index cd80f25df..ec67acc64 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -27,7 +27,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | D. Search & filter (FILT) | 2 | 7 | 4 | 0 | 2/4 (50%) | 0/4 (0%) | | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | | F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | -| G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | +| G. Profiles (PROF) | 4 | 1 | 5 | 0 | 2/3 (67%) | 1/3 (33%) | | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 42 | 19 | 24 | 8 | 37/47 (79%) | 3/23 (13%) | | Z. Keyboard shortcuts (desktop) | 45 | 16 | 19 | 1 | 39/52 (75%) | 6/23 (26%) | | Lightroom Classic extras | 1 | 17 | 61 | 9 | — | 1/21 (5%) | -| **Total** | 190 | 107 | 203 | 37 | 150/198 (76%) | 34/144 (24%) | +| **Total** | 191 | 107 | 202 | 37 | 150/198 (76%) | 34/144 (24%) | ## Top gaps @@ -297,7 +297,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ⬜ | | | +| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | | LR-PROF-AMOUNT | Profile amount | P1 | ✅ | `ctl:profile.amount` | |