diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index 697a7148..ebbd5315 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -22,6 +22,7 @@ pub enum Section { Optics, Geometry, Profile, + Calibration, } impl Section { @@ -40,6 +41,7 @@ impl Section { Section::Optics => "Optics", Section::Geometry => "Geometry", Section::Profile => "Profile", + Section::Calibration => "Calibration", } } } @@ -227,6 +229,13 @@ 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; + "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" }; + "calibration.greenHue" => calibration.green_hue, "Green Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#b0c030", to: "#30c08a" }; + "calibration.greenSat" => calibration.green_sat, "Green Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40b050" }; + "calibration.blueHue" => calibration.blue_hue, "Blue Hue", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#30a0d0", to: "#8040d0" }; + "calibration.blueSat" => calibration.blue_sat, "Blue Saturation", Calibration, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#3a60e0" }; "crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered; } diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 349d700b..e4ebc755 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -32,10 +32,11 @@ pub enum SettingsGroup { Spots, RedEye, LensBlur, + Calibration, } impl SettingsGroup { - pub const ALL: [SettingsGroup; 19] = [ + pub const ALL: [SettingsGroup; 20] = [ SettingsGroup::Profile, SettingsGroup::Treatment, SettingsGroup::WhiteBalance, @@ -55,6 +56,7 @@ impl SettingsGroup { SettingsGroup::Spots, SettingsGroup::RedEye, SettingsGroup::LensBlur, + SettingsGroup::Calibration, ]; /// Groups included by default when copying (Lightroom excludes crop, masks and spots by default). @@ -86,6 +88,7 @@ impl SettingsGroup { SettingsGroup::Spots => "Remove", SettingsGroup::RedEye => "Red Eye", SettingsGroup::LensBlur => "Lens Blur", + SettingsGroup::Calibration => "Calibration", } } @@ -111,6 +114,7 @@ impl SettingsGroup { SettingsGroup::Spots => &["spots"], SettingsGroup::RedEye => &["red_eye"], SettingsGroup::LensBlur => &["lens_blur"], + SettingsGroup::Calibration => &["calibration"], } } } diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index fa257c36..3e0326cb 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -32,6 +32,8 @@ pub struct DevelopSettings { pub red_eye: Vec, pub lens_blur: LensBlur, pub enhance: Enhance, + /// Camera calibration: shadows tint and primary hue/saturation (applied before tone mapping). + pub calibration: Calibration, /// Section on/off toggles (the "eye" buttons on panel headers): section id → enabled. pub disabled_sections: Vec, } @@ -62,6 +64,7 @@ impl Default for DevelopSettings { red_eye: Vec::new(), lens_blur: LensBlur::default(), enhance: Enhance::default(), + calibration: Calibration::default(), disabled_sections: Vec::new(), } } @@ -319,6 +322,31 @@ impl ColorGrading { } } +/// Camera calibration (Lightroom Classic's Calibration panel): a green/magenta tint of the shadows, +/// and a hue rotation / saturation scale of each working-space primary (−100..100), i.e. a +/// white-preserving 3×3 matrix applied in scene-linear light before tone mapping. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Calibration { + pub shadows_tint: f64, + pub red_hue: f64, + pub red_sat: f64, + pub green_hue: f64, + pub green_sat: f64, + pub blue_hue: f64, + pub blue_sat: f64, +} + +impl Calibration { + /// (hue, saturation) of the red, green and blue primaries. + pub fn primaries(&self) -> [(f64, f64); 3] { + [(self.red_hue, self.red_sat), (self.green_hue, self.green_sat), (self.blue_hue, self.blue_sat)] + } + pub fn is_neutral(&self) -> bool { + *self == Calibration::default() + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Effects { diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index d35641e7..d39c0a72 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -261,6 +261,15 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { n(o, "DefringeGreenHueLo", "optics.defringe_green_hue_lo"); n(o, "DefringeGreenHueHi", "optics.defringe_green_hue_hi"); + // ---- Calibration + n(o, "ShadowTint", "calibration.shadows_tint"); + n(o, "RedHue", "calibration.red_hue"); + n(o, "RedSaturation", "calibration.red_sat"); + n(o, "GreenHue", "calibration.green_hue"); + n(o, "GreenSaturation", "calibration.green_sat"); + n(o, "BlueHue", "calibration.blue_hue"); + n(o, "BlueSaturation", "calibration.blue_sat"); + // ---- Geometry n(o, "PerspectiveVertical", "geometry.vertical"); n(o, "PerspectiveHorizontal", "geometry.horizontal"); @@ -337,6 +346,8 @@ mod tests { crs:LuminanceSmoothing="18" crs:ColorNoiseReduction="25" crs:PostCropVignetteAmount="-21" crs:PostCropVignetteStyle="2" crs:GrainAmount="15" crs:GrainSize="30" crs:GrainFrequency="60" crs:PerspectiveUpright="2" crs:PerspectiveVertical="-10" crs:AutoLateralCA="1" crs:ConvertToGrayscale="False" + crs:ShadowTint="-7" crs:RedHue="+12" crs:RedSaturation="+20" crs:GreenHue="-5" crs:GreenSaturation="+3" + crs:BlueHue="-15" crs:BlueSaturation="+25" crs:HasCrop="True" crs:CropTop="0.1" crs:CropLeft="0.05" crs:CropBottom="0.9" crs:CropRight="0.8" crs:CropAngle="1.5"> 0, 064, 52192, 205255, 255 @@ -384,6 +395,11 @@ mod tests { assert_eq!(s.geometry.vertical, -10.0); assert!(s.optics.remove_ca); assert_eq!(s.treatment, lightcraft_develop::Treatment::Color); + let cal = s.calibration; + assert_eq!( + (cal.shadows_tint, cal.red_hue, cal.red_sat, cal.green_hue, cal.green_sat, cal.blue_hue, cal.blue_sat), + (-7.0, 12.0, 20.0, -5.0, 3.0, -15.0, 25.0) + ); let c = s.crop.geometry; assert_eq!((c.rect.x0, c.rect.y0, c.rect.x1, c.rect.y1, c.angle), (0.05, 0.1, 0.8, 0.9, 1.5)); } diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index b71471f6..f05f22f1 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -118,6 +118,7 @@ fn constants() -> String { let b = lightcraft_pipeline::geometry::BLANK; s += &format!("const BLANK_R: f32 = {:?};\nconst BLANK_G: f32 = {:?};\nconst BLANK_B: f32 = {:?};\n", b[0], b[1], b[2]); s += &format!("const SRGB_N: u32 = {SRGB_LUT_N}u;\nconst CURVE_N: u32 = {}u;\nconst MASK_TERMS: u32 = {MASK_TERMS}u;\n", crate::params::CURVE_N); + s += &format!("const SHADOW_TINT_K: f32 = {:?};\n", lightcraft_pipeline::colorops::SHADOW_TINT); for (i, h) in GRAIN_HASH.iter().enumerate() { s += &format!("const GRAIN_H{i}: u32 = {h}u;\n"); } diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index 64d722d7..d05d622f 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -59,6 +59,9 @@ const FIELDS: &[(&str, usize)] = &[ ("GRAIN_ROUGH", 1), ("GRAIN_SEED", 1), ("GRAIN_AFF", 6), + ("CALIB", 1), + ("CALIB_M", 9), + ("SHADOW_TINT", 1), ]; /// `(name, index)` of every field (for the WGSL constants). @@ -196,6 +199,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr }, ); } + if let Some(m) = &fp.calib { + p.b("CALIB", true); + p.fs("CALIB_M", m.as_flattened()); + } + p.f("SHADOW_TINT", fp.shadow_tint); if let Some((amt, cell, rough, seed)) = fp.grain { p.b("GRAIN", true); p.f("GRAIN_AMT", amt); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index 0548ab5d..8528d30c 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -117,6 +117,28 @@ fn color_ops(rgb: vec3, local_sat: f32, local_hue: f32) -> vec3 { return oklab_inv(lab); } +// `colorops::calibrate`: primaries matrix, then the shadows tint (luminance kept). +fn calibrate(c0: vec3) -> vec3 { + var c = c0; + if (pu(F_CALIB) != 0u) { + let m = array, 3>( + vec3(pf(F_CALIB_M), pf(F_CALIB_M + 1u), pf(F_CALIB_M + 2u)), + vec3(pf(F_CALIB_M + 3u), pf(F_CALIB_M + 4u), pf(F_CALIB_M + 5u)), + vec3(pf(F_CALIB_M + 6u), pf(F_CALIB_M + 7u), pf(F_CALIB_M + 8u)), + ); + c = max(mul3(m, c), vec3(0.0)); + } + let st = pf(F_SHADOW_TINT); + if (st != 0.0) { + let y0 = lum2020(c); + let w = 1.0 - sstep(-5.0, -0.5, log2(max(y0, 1e-7) / 0.18)); + c.y *= max(1.0 - SHADOW_TINT_K * st * w, 0.0); + let y1 = max(lum2020(c), 1e-9); + c = c * y0 / y1; + } + return c; +} + fn ghash(i: i32, j: i32, seed: u32) -> f32 { var v = (bitcast(i) * GRAIN_H0) ^ (bitcast(j) * GRAIN_H1) ^ (seed * GRAIN_H2); v ^= v >> 13u; @@ -263,6 +285,11 @@ fn main(@builtin(global_invocation_id) gid: vec3) { c = c * exp2(delta); } + // --- calibration (scene linear, before the tone map) + if (pu(F_CALIB) != 0u || pf(F_SHADOW_TINT) != 0.0) { + c = calibrate(c); + } + // --- tone map on luminance, highlight desaturation let yl = lum2020(c); let o = tone_apply(yl); diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 474d3404..b479dcf7 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -253,6 +253,14 @@ fn cases() -> Vec<(&'static str, Edit)> { }, ]; }), + ("calibration", |s| { + s.calibration.shadows_tint = 40.0; + s.calibration.red_hue = 50.0; + s.calibration.red_sat = -30.0; + s.calibration.green_hue = -40.0; + s.calibration.blue_sat = 60.0; + s.calibration.blue_hue = 25.0; + }), ("spots + defringe (cpu stage)", |s| { s.spots = vec![Spot { points: vec![Point::new(0.3, 0.3)], size: 0.03, source_offset: Some(Point::new(0.1, 0.0)), ..Default::default() }]; s.optics.defringe_purple_amount = 5.0; diff --git a/crates/pipeline/src/colorops.rs b/crates/pipeline/src/colorops.rs index 5d378d19..96f67e87 100644 --- a/crates/pipeline/src/colorops.rs +++ b/crates/pipeline/src/colorops.rs @@ -1,12 +1,12 @@ //! Colour tools in OkLCh on display-linear Rec.2020 values: vibrance, saturation, the 8-band colour -//! mixer, B&W mix, and 3-way colour grading. +//! mixer, B&W mix, and 3-way colour grading; plus the camera-calibration matrix (scene linear). use std::f32::consts::{PI, TAU}; use std::sync::OnceLock; use lightcraft_color::perceptual::{hsv_to_rgb, lab_to_lch, lch_to_lab, oklab_from_2020, oklab_to_2020}; -use lightcraft_color::{REC2020, SRGB}; -use lightcraft_develop::{DevelopSettings, MIXER_HUES}; +use lightcraft_color::{Mat3, REC2020, SRGB}; +use lightcraft_develop::{Calibration, DevelopSettings, MIXER_HUES}; /// OkLCh hue angle (radians) of a pure sRGB colour with HSV hue `deg`. pub fn oklch_hue_of_srgb_hue(deg: f64) -> f32 { @@ -158,6 +158,57 @@ impl ColorOps { } } +/// Hue rotation (OkLCh radians) of a calibration primary at ±100. +pub const CALIB_HUE: f32 = 0.5; +/// Chroma scale of a calibration primary at ±100 (`1 ± CALIB_SAT`). +pub const CALIB_SAT: f32 = 0.6; + +/// The calibration panel's primaries as a white-preserving 3×3 matrix on linear Rec.2020 (row-major): +/// each primary is rotated in OkLCh hue and scaled in chroma at constant OkLab lightness, then the +/// columns are rescaled so that neutral (1, 1, 1) maps to itself. `None` when neutral. +pub fn calibration_matrix(c: &Calibration) -> Option<[[f32; 3]; 3]> { + let prim = c.primaries(); + if prim.iter().all(|(h, s)| *h == 0.0 && *s == 0.0) { + return None; + } + let mut p = [[0.0f64; 3]; 3]; + for (i, (hue, sat)) in prim.iter().enumerate() { + let mut e = [0.0f32; 3]; + e[i] = 1.0; + let [l, ch, h] = lab_to_lch(oklab_from_2020(e)); + let h2 = h + (hue.clamp(-100.0, 100.0) / 100.0) as f32 * CALIB_HUE; + let c2 = ch * (1.0 + (sat.clamp(-100.0, 100.0) / 100.0) as f32 * CALIB_SAT); + let q = oklab_to_2020(lch_to_lab([l, c2, h2])); + for (r, row) in p.iter_mut().enumerate() { + row[i] = q[r] as f64; + } + } + let m = Mat3(p); + let k = m.inverse()?.apply([1.0, 1.0, 1.0]); + Some(std::array::from_fn(|r| std::array::from_fn(|col| (p[r][col] * k[col]) as f32))) +} + +/// Shadows-tint strength at ±100: the green channel's relative change in deep shadows. +pub const SHADOW_TINT: f32 = 0.3; + +/// Calibration in scene-linear light (before tone mapping): the primaries matrix, then the shadows +/// tint (green ↔ magenta, weighted towards dark tones, luminance kept). +#[inline] +pub fn calibrate(c: [f32; 3], m: Option<&[[f32; 3]; 3]>, shadow_tint: f32) -> [f32; 3] { + let mut c = c; + if let Some(m) = m { + c = std::array::from_fn(|r| (m[r][0] * c[0] + m[r][1] * c[1] + m[r][2] * c[2]).max(0.0)); + } + if shadow_tint != 0.0 { + let y0 = lightcraft_color::luminance_2020(c); + let w = 1.0 - smooth(-5.0, -0.5, (y0.max(1e-7) / 0.18).log2()); + c[1] *= (1.0 - SHADOW_TINT * shadow_tint * w).max(0.0); + let y1 = lightcraft_color::luminance_2020(c).max(1e-9); + c = c.map(|v| v * y0 / y1); + } + c +} + #[inline] fn smooth(e0: f32, e1: f32, x: f32) -> f32 { let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); @@ -219,6 +270,36 @@ mod tests { assert!((cr - cr0).abs() < 0.01); } + #[test] + fn calibration_keeps_white_and_moves_primaries() { + assert!(calibration_matrix(&Calibration::default()).is_none()); + let cal = Calibration { red_hue: 60.0, blue_sat: -50.0, ..Default::default() }; + let m = calibration_matrix(&cal).unwrap(); + let grey = calibrate([0.3, 0.3, 0.3], Some(&m), 0.0); + assert!(grey.iter().all(|v| (v - 0.3).abs() < 1e-4), "{grey:?}"); + // red rotates in hue, blue loses chroma + let red = [0.4, 0.05, 0.03]; + let h0 = lab_to_lch(oklab_from_2020(red))[2]; + let h1 = lab_to_lch(oklab_from_2020(calibrate(red, Some(&m), 0.0)))[2]; + assert!(h1 - h0 > 0.05, "{h0} -> {h1}"); + let blue = [0.03, 0.05, 0.4]; + let c0 = lab_to_lch(oklab_from_2020(blue))[1]; + let c1 = lab_to_lch(oklab_from_2020(calibrate(blue, Some(&m), 0.0)))[1]; + assert!(c1 < c0 * 0.97, "{c0} -> {c1}"); + } + + #[test] + fn shadow_tint_targets_shadows() { + let dark = calibrate([0.01, 0.01, 0.01], None, 1.0); + let bright = calibrate([0.8, 0.8, 0.8], None, 1.0); + assert!(dark[1] < dark[0] * 0.85, "magenta shadows: {dark:?}"); + assert!((bright[1] - bright[0]).abs() < 1e-3, "{bright:?}"); + let y = |c: [f32; 3]| lightcraft_color::luminance_2020(c); + assert!((y(dark) - 0.01).abs() < 1e-5); + let green = calibrate([0.01, 0.01, 0.01], None, -1.0); + assert!(green[1] > green[0]); + } + #[test] fn grading_tints_shadows_only() { let mut s = DevelopSettings::default(); diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 5a79a2de..fc14f71c 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -145,6 +145,9 @@ pub fn mask_terms(j: &LocalAdjustments) -> [f32; MASK_TERMS] { pub struct FinishParams { pub tone: ToneMap, pub ops: ColorOps, + /// Calibration: primaries matrix (row-major, linear Rec.2020) and shadows tint (−1..1). + pub calib: Option<[[f32; 3]; 3]>, + pub shadow_tint: f32, /// Tone curves (parametric ∘ point, per channel) on encoded values, 1024 entries each. pub curves: Option<[Lut1; 3]>, pub vig: Option, @@ -187,7 +190,10 @@ impl FinishParams { let cell = (0.0006 + (s.grain.size / 100.0) as f32 * 0.0024) * px_per_long as f32; ((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed) }); + let calibration = s.section_enabled("calibration"); FinishParams { + calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None }, + shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 }, tone: if info.raw { ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks) } else { @@ -330,6 +336,11 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So c = c.map(|v| v * g); } + // --- calibration (scene linear, before the tone map) + if fp.calib.is_some() || fp.shadow_tint != 0.0 { + c = crate::colorops::calibrate(c, fp.calib.as_ref(), fp.shadow_tint); + } + // --- tone map on luminance, highlight desaturation let yl = luminance_2020(c); let o = tone.apply(yl); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 0361cb95..e3b928f5 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -230,3 +230,32 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() { let d = max_diff(&a, &b); assert!(d <= 2, "max difference {d}"); } + +#[test] +fn calibration_shifts_colours_but_keeps_greys() { + let info = SourceInfo { raw: true, ..Default::default() }; + let req = RenderRequest::fit(64, 8); + // a grey ramp is untouched by the primaries + let grey = Rgb32f::from_fn(64, 8, |x, _| [0.002 * 1.12f32.powi(x as i32); 3]); + let mut s = DevelopSettings::default(); + s.calibration.red_hue = 80.0; + s.calibration.green_sat = -60.0; + let a = render(&grey, &info, &DevelopSettings::default(), &req).image; + let b = render(&grey, &info, &s, &req).image; + assert!(max_diff(&a, &b) <= 1); + // a red patch rotates towards yellow (green rises relative to blue) + let red = Rgb32f::filled(16, 16, [0.3, 0.04, 0.03]); + let r0 = render(&red, &info, &DevelopSettings::default(), &RenderRequest::fit(16, 16)).image.data[0]; + let r1 = render(&red, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(r1[1] as i32 - r1[2] as i32 > r0[1] as i32 - r0[2] as i32 + 5, "{r0:?} -> {r1:?}"); + // shadows tint: magenta (+) lowers green in a dark grey + let dark = Rgb32f::filled(16, 16, [0.01, 0.01, 0.01]); + s = DevelopSettings::default(); + s.calibration.shadows_tint = 100.0; + let d = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(d[1] + 3 < d[0], "{d:?}"); + // the section toggle disables it + s.set_section_enabled("calibration", false); + let off = render(&dark, &info, &s, &RenderRequest::fit(16, 16)).image.data[0]; + assert!(off[1].abs_diff(off[0]) <= 1, "{off:?}"); +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 0ffd398a..2bfd26cb 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -261,6 +261,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } 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, "Shadows"); + control(app, ui, d, "calibration.shadowsTint", true); + for (title, k) in [("Red Primary", "red"), ("Green Primary", "green"), ("Blue Primary", "blue")] { + sub_title(ui, title); + control(app, ui, d, &format!("calibration.{k}Hue"), true); + control(app, ui, d, &format!("calibration.{k}Sat"), true); + } + ui.add_space(8.0); + }); ui.add_space(40.0); } diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index cfdc6ac3..0de40894 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -72,6 +72,7 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`). | `LensProfileEnable`, `AutoLateralCA` | `optics.lens_profile`, `optics.remove_ca` | the switch only; lens profiles are our own | | `LensManualDistortionAmount`, `VignetteAmount`, `VignetteMidpoint` | `optics.distortion`, `optics.vignetting`, `optics.vignetting_midpoint` | | | `DefringePurple/GreenAmount/HueLo/HueHi` | `optics.defringe_*` | | +| `ShadowTint`, `RedHue/Saturation`, `GreenHue/Saturation`, `BlueHue/Saturation` | `calibration.shadows_tint`, `calibration.red_hue/red_sat`, … | Calibration panel, 1:1 | | `PerspectiveVertical/Horizontal/Rotate/Scale/Aspect/X/Y` | `geometry.vertical/horizontal/rotate/scale/aspect/offset_x/offset_y` | | | `PerspectiveUpright` | `geometry.upright` | 0 off, 1 auto, 2 level, 3 vertical, 4 full, 5 guided | | `HasCrop`, `CropLeft/Top/Right/Bottom`, `CropAngle` | `crop.geometry` | normalized edges → rect; angle in degrees; `HasCrop="False"` → no crop |