diff --git a/Cargo.lock b/Cargo.lock index 0ad4ab843..c8766ae9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3000,6 +3000,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-geom", "lightcraft-meta", + "lightcraft-raw", "log", "proptest", "serde", diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 4796740ea..d5b18f9e4 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -36,6 +36,8 @@ pub struct WireJob { pub raw: bool, #[serde(default)] pub relative_wb: bool, + #[serde(default)] + pub legacy_relative_wb: bool, pub as_shot_temp: f64, pub as_shot_tint: f64, pub lens: Option, @@ -74,6 +76,7 @@ impl WireJob { max_edge, raw: job.info.raw, relative_wb: job.info.relative_wb, + legacy_relative_wb: job.info.legacy_relative_wb, as_shot_temp: job.info.as_shot_temp, as_shot_tint: job.info.as_shot_tint, lens: job.info.lens, @@ -97,6 +100,7 @@ impl WireJob { as_shot_tint: self.as_shot_tint, lens: self.lens, relative_wb: self.relative_wb, + legacy_relative_wb: self.legacy_relative_wb, ..Default::default() } } @@ -421,6 +425,7 @@ mod tests { max_edge: 512, raw: false, relative_wb: false, + legacy_relative_wb: false, as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 604fb0397..97d8868de 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] lightcraft-develop = { workspace = true } lightcraft-meta = { workspace = true } +lightcraft-raw = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index f52d08db8..697ddf010 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -49,6 +49,7 @@ //! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` | //! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` | //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | +//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -73,7 +74,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 = 6; +pub const VERSION: u32 = 7; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index ed6150847..cc24266f2 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -214,6 +214,13 @@ pub enum Op { id: PhotoId, lens: Option>, }, + /// A raw's as-shot white balance (Kelvin, tint) as its file reads now: what a decode or Reload finds when the + /// camera's colour matrices give the file a Kelvin scale it was imported without (issue #730). Bookkeeping, + /// not an edit: the look does not change, only the As Shot readout. + SetAsShotWb { + id: PhotoId, + wb: Option<(f64, f64)>, + }, /// The name shown for a colour label (`None` = its colour's name). SetLabelName { label: ColorLabel, @@ -685,6 +692,13 @@ impl Catalog { let p = self.photo_mut(id)?; Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) } } + Op::SetAsShotWb { id, wb } => { + if wb.is_some_and(|(t, tint)| !(t.is_finite() && tint.is_finite())) { + return Err(CatalogError::Invalid("as-shot white balance is not a number".into())); + } + let p = self.photo_mut(id)?; + Op::SetAsShotWb { id, wb: std::mem::replace(&mut p.as_shot_wb, wb) } + } Op::SetFile { id, file_name, source } => { if file_name.trim().is_empty() { return Err(CatalogError::Invalid("empty file name".into())); diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index e81d48805..ff83a179d 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -284,7 +284,8 @@ impl Op { | Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } - | Op::SetEmbeddedLens { id, .. } => out(*id), + | Op::SetEmbeddedLens { id, .. } + | Op::SetAsShotWb { id, .. } => out(*id), Op::AddAlbum { album } => { album.photos.iter().for_each(|p| out(*p)); album.cover.into_iter().for_each(&mut *out); diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 199edac1b..abeaadad4 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -307,14 +307,25 @@ pub struct Analysis { pub best: bool, } -/// Raw formats whose readers have vendor white-balance multipliers but no measured camera -/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means -/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's -/// `fileFormat` name, in any case. See [`Photo::relative_wb`]. +/// Raw formats whose files carry vendor white-balance multipliers but no colour matrices: their starting +/// look is fitted to the file's own JPEG (the engine's `camera_preview::file_local_look` lists the same +/// formats). `format` is the file's extension or Lightroom's `fileFormat` name, in any case. Whether such a +/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera: +/// [`relative_wb_camera`]. pub fn relative_wb_format(format: &str) -> bool { ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f)) } +/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed +/// with a white balance relative to its as-shot look (6500 K / 0 means as shot, as for rendered photographs): +/// a file without colour matrices ([`relative_wb_format`]) whose camera has none measured either. A camera +/// with matrices in `lightcraft_raw::spectral` edits in Kelvin: the matrices turn its as-shot multipliers into +/// a temperature, and the slider back, while its colour look stays whatever the file's fit gives (issue #730). +/// The engine's render path (`files.rs`) decides the same way from the file; both must agree. +pub fn relative_wb_camera(format: &str, camera: &str) -> bool { + relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() +} + impl Photo { pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo { Photo { @@ -355,13 +366,18 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF readers have vendor WB multipliers but no measured camera - /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered - /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and - /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files). - pub fn relative_wb(&self) -> bool { + /// A raw developed from a look fitted to its own JPEG (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF: vendor + /// WB multipliers, no colour matrices in the file; RWL and RAW are Leica's and the oldest Panasonic bodies' + /// names for RW2 files). The engine's `camera_preview::file_local_look` covers the same formats. + pub fn fitted_look(&self) -> bool { self.develops_raw() && relative_wb_format(&self.format) } + /// White balance relative to the camera's as-shot look, as for rendered photographs: a + /// [`Photo::fitted_look`] raw whose camera has no colour matrices measured either + /// ([`relative_wb_camera`]). Other raws edit white balance in Kelvin. + pub fn relative_wb(&self) -> bool { + self.develops_raw() && relative_wb_camera(&self.format, &self.meta.camera) + } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's /// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults, /// in the photo's own rendering process. @@ -377,11 +393,15 @@ impl Photo { /// process a photo renders with is not an edit, so comparisons with its defaults (edited? /// still as imported?) don't change when a newer process exists. A new photo has the latest. pub fn camera_defaults(&self) -> DevelopSettings { - let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb }; + let relative = self.relative_wb(); + let wb = if relative { Some((6500.0, 0.0)) } else { self.as_shot_wb }; let mut d = match wb { Some((t, tint)) => DevelopSettings::for_raw(t, tint), None => DevelopSettings::default(), }; + if self.develops_raw() && !relative { + d.wb.scale = lightcraft_develop::WbScale::Kelvin; + } if self.embedded_lens.is_some() { d.optics.lens_profile = true; } @@ -487,6 +507,7 @@ impl Album { #[cfg(test)] mod edited_tests { use super::*; + use lightcraft_develop::WbScale; #[test] fn raw_with_import_settings_is_not_edited() { @@ -547,10 +568,13 @@ mod edited_tests { ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); p.kind = MediaKind::Raw; + p.meta.camera = "Pentax K-1".into(); p.as_shot_wb = Some((5200.0, 4.0)); assert_eq!(p.relative_wb(), relative, "{format}"); + assert_eq!(p.fitted_look(), relative, "{format}"); let wb = p.camera_defaults().wb; assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}"); + assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{format}"); p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0)); assert!(!p.is_edited(), "{format}: old As Shot values are not an edit"); let mut custom = (*p.develop).clone(); @@ -559,7 +583,43 @@ mod edited_tests { assert!(p.is_edited(), "{format}: Custom WB is still an edit"); // shown from the embedded preview: a rendered image, not a relative-WB raw p.preview_only = Some("unsupported".into()); - assert!(!p.relative_wb()); + assert!(!p.relative_wb() && !p.fitted_look()); + } + } + + /// A raw without matrices in the file edits in Kelvin when its camera's matrices are measured (issue #730): + /// the decision reads the catalog's camera string, and the photo's defaults say which scale they are on. + #[test] + fn raws_of_cameras_with_measured_matrices_use_kelvin_white_balance() { + for (camera, format, relative) in [ + ("SONY ILCE-7M3", "ARW", false), + ("SONY ILCE-7RM4A", "ARW", false), + ("NIKON CORPORATION NIKON D850", "NEF", false), + ("Canon Canon EOS 5D Mark III", "CR2", false), + ("FUJIFILM X-T4", "RAF", false), + ("ILCE-7M3", "ARW", false), + ("SONY ILCE-7M2", "ARW", true), + ("SONY ILCE-1", "ARW", true), + ("", "ARW", true), + ("SONY ILCE-7M3", "DNG", false), + ] { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.raw", format, 10, 10, "2026-10-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = camera.into(); + p.as_shot_wb = Some((4986.0, -2.0)); + assert_eq!(p.relative_wb(), relative, "{camera} {format}"); + assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}"); + assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}"); + let wb = p.camera_defaults().wb; + assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}"); + assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}"); + // a photo from an older catalog: stored As Shot on the relative scale, still not an edit + p.develop = Arc::new(DevelopSettings::for_raw(6500.0, 0.0)); + assert!(!p.is_edited(), "{camera} {format}: old As Shot values are not an edit"); + // its as-shot white not yet read through the matrices (6500 / 0 from the old import): defaults still As Shot + p.as_shot_wb = Some((6500.0, 0.0)); + assert_eq!(p.camera_defaults().wb.mode, WbMode::AsShot); + assert!(!p.is_edited()); } } } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 20f4b7282..2b5bbe476 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -476,6 +476,27 @@ fn set_embedded_lens_is_undoable_and_journaled() { assert_eq!(c.photo(a).unwrap().embedded_lens, None); } +/// A decode or Reload stores the as-shot white a raw reads through its camera's matrices; the inverse (through the +/// journal's JSON) takes it back, and a value that is not a number is refused. +#[test] +fn set_as_shot_wb_is_undoable_journaled_and_checked() { + let mut c = Catalog::default(); + let a = c.alloc_photo_id(); + let mut p = Photo::new(a, Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 4000, 3000, "2026-10-06T00:00:00"); + p.as_shot_wb = Some((6500.0, 0.0)); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let op = Op::SetAsShotWb { id: a, wb: Some((4986.0, -2.0)) }; + let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap(); + let inv = c.apply(op).unwrap(); + assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((4986.0, -2.0))); + let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); + c.apply(inv).unwrap(); + assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0))); + assert!(c.apply(Op::SetAsShotWb { id: a, wb: Some((f64::NAN, 0.0)) }).is_err()); + assert!(c.apply(Op::SetAsShotWb { id: PhotoId(999), wb: None }).is_err()); + assert_eq!(c.photo(a).unwrap().as_shot_wb, Some((6500.0, 0.0))); +} + /// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found, /// with its problems; a good one has none; an old album operator is read as it was meant. #[test] diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index ed2bfe7d1..37bd639e8 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -210,8 +210,9 @@ fn op_variants_are_versioned() { Op::SetEmbeddedLens { .. } => 4, Op::SetKeyword { .. } => 5, Op::SetFolderRecord { .. } => 6, + Op::SetAsShotWb { .. } => 7, } } - let newest = since(&Op::SetFolderRecord { folder: "/".into(), record: None }); + let newest = since(&Op::SetAsShotWb { id: PhotoId(1), wb: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 7d137d569..855c3d049 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -149,7 +149,8 @@ impl RgbSpace { } } -const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]); +/// The Bradford cone-response matrix (XYZ → sharpened cones) that [`bradford`] adapts in. +pub const BRADFORD: Mat3 = Mat3([[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]); /// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`. pub fn bradford(src: Xy, dst: Xy) -> Mat3 { diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index c2edd76c9..f376b3c67 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -38,11 +38,21 @@ impl DevelopSettings { serde_json::from_value(v.clone()) } - /// Merge a partial JSON object into these settings (fields not mentioned are kept). + /// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that + /// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a + /// partial from before the scale existed, or one that never said, must not become Kelvin by landing on + /// Kelvin settings. pub fn merged(&self, partial: &Value) -> Result { let mut v = self.to_json(); presets::deep_merge(&mut v, partial); - DevelopSettings::from_json(&v) + let mut out = DevelopSettings::from_json(&v)?; + if let Some(wb) = partial.get("wb").and_then(Value::as_object) + && (wb.contains_key("temp") || wb.contains_key("tint")) + && !wb.contains_key("scale") + { + out.wb.scale = WbScale::Legacy; + } + Ok(out) } /// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys. @@ -167,6 +177,47 @@ mod tests { assert_ne!(back.hash64(), DevelopSettings::default().hash64()); } + #[test] + fn white_balance_scale_reads_absent_as_legacy_and_writes_only_kelvin() { + // settings from before the scale existed: no field, `Legacy`, and written back without it + let old = json!({"wb": {"mode": "custom", "temp": 5800.0, "tint": 3.0}}); + let s = DevelopSettings::from_json(&old).unwrap(); + assert_eq!(s.wb.scale, WbScale::Legacy); + assert_eq!(s.to_json()["wb"], old["wb"]); + assert_eq!(DevelopSettings::default().wb.scale, WbScale::Legacy); + assert!(DevelopSettings::default().to_json()["wb"].get("scale").is_none()); + // written on the Kelvin scale: said so, and read back + let mut k = s.clone(); + k.wb.scale = WbScale::Kelvin; + assert_eq!(k.to_json()["wb"]["scale"], json!("kelvin")); + assert_eq!(DevelopSettings::from_json(&k.to_json()).unwrap(), k); + assert_ne!(k.hash64(), s.hash64()); + // an unknown scale from a newer build is an error, not a panic + assert!(DevelopSettings::from_json(&json!({"wb": {"scale": "mired"}})).is_err()); + } + + #[test] + fn a_partial_white_balance_without_a_scale_is_legacy() { + let mut base = DevelopSettings::default(); + base.wb.scale = WbScale::Kelvin; + // an old preset's white balance lands on Kelvin settings: it keeps its relative meaning + let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0}}), 1.0); + assert_eq!((out.wb.scale, out.wb.temp), (WbScale::Legacy, 5800.0)); + let out = apply_partial(&base, &json!({"wb": {"tint": 12.0}}), 1.0); + assert_eq!(out.wb.scale, WbScale::Legacy); + // one that says Kelvin stays Kelvin; a mode alone leaves the scale as it was + let out = apply_partial(&base, &json!({"wb": {"mode": "custom", "temp": 5800.0, "scale": "kelvin"}}), 1.0); + assert_eq!(out.wb.scale, WbScale::Kelvin); + let out = apply_partial(&base, &json!({"wb": {"mode": "cloudy"}}), 1.0); + assert_eq!(out.wb.scale, WbScale::Kelvin); + let out = apply_partial(&base, &json!({"light": {"exposure": 1.0}}), 1.0); + assert_eq!(out.wb.scale, WbScale::Kelvin); + // the plain merge (`develop.merge`) follows the same rule + assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0}})).unwrap().wb.scale, WbScale::Legacy); + assert_eq!(base.merged(&json!({"wb": {"temp": 5800.0, "scale": "kelvin"}})).unwrap().wb.scale, WbScale::Kelvin); + assert_eq!(base.merged(&json!({"wb": {"mode": "auto"}})).unwrap().wb.scale, WbScale::Kelvin); + } + #[test] fn default_hdr_is_not_serialized_so_existing_hashes_stay() { let s = DevelopSettings::default(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 8bd150600..acaa303fc 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -187,6 +187,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { let base = s.to_json(); let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) }; + // (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`) let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone()); for c in crate::controls::CONTROLS { if let Some(v) = crate::controls::get(&out, c.id) { diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 54577bffc..11afae927 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -242,7 +242,13 @@ impl WbMode { WbMode::Custom => "Custom", } } - /// Preset temperature/tint for raw files (K, tint). + /// Preset temperature/tint for raw files with a Kelvin scale (K, tint): the CIE daylight series D55 / D65 / + /// D75 (5503, 6504, 7504 K; CIE 15:2004) for Daylight / Cloudy / Shade, CIE Illuminant A (2856 K) for + /// Tungsten, a typical cool-white fluorescent (CIE F2, 4230 K, is the lamp; 3800 K with a magenta correction + /// is what a camera's preset neutralises) and a flash at daylight; the tints are the small magenta + /// corrections daylight-series whites need above the Planckian locus, as Lightroom's presets of the same + /// names show them in its panel (observed values, no Adobe data). On the relative scale (rendered files, raws + /// without matrices) `develop.wb` scales them around 6500 K = as shot. pub fn preset(self) -> Option<(f64, f64)> { match self { WbMode::Daylight => Some((5500.0, 10.0)), @@ -256,9 +262,35 @@ impl WbMode { } } -/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files -/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an -/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom). +/// Which scale a [`WhiteBalance`]'s `temp` / `tint` were written on. +/// +/// Raws whose camera has colour matrices (in the file, or measured for the model) edit white balance in Kelvin; +/// before that, raws without matrices in the file were all edited relative to their as-shot look, where 6500 K / 0 +/// means *as shot*. Settings written then carry no scale and keep that meaning (`Legacy`): the pipeline turns them +/// into the same colour change on the Kelvin scale (`lightcraft_pipeline::local::legacy_to_kelvin`), so no photo's +/// look changes. Settings written on the Kelvin scale say so (`Kelvin`). The scale is irrelevant on files the +/// Kelvin scale does not reach (rendered photos, raws without matrices): their values keep the relative meaning. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WbScale { + /// Written before the scale existed, or by a preset / partial JSON without one: relative (6500 K / 0 = as shot) + /// on a raw developed from a fitted look, Kelvin on a raw whose file carries matrices. Not written. + #[default] + Legacy, + /// Absolute Kelvin and tint, as Lightroom writes them for raws. + Kelvin, +} + +impl WbScale { + fn is_legacy(&self) -> bool { + *self == WbScale::Legacy + } +} + +/// White balance. For raw files with colour matrices `temp` is the scene illuminant in Kelvin (`scale` says the +/// values were written on that scale); for rendered files (JPEG etc.) and raws without matrices the image is +/// assumed D65-balanced and `temp`/`tint` are the same model with an "as shot" of 6500 K / 0 (the UI shows a +/// relative −100..100 scale for those, like Lightroom). #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct WhiteBalance { @@ -266,11 +298,17 @@ pub struct WhiteBalance { pub temp: f64, /// Correction direction: negative adds green, positive adds magenta. pub tint: f64, + /// The scale `temp` / `tint` were written on; left out of the JSON when [`WbScale::Legacy`], so settings + /// from before it existed read and write unchanged. + #[serde(skip_serializing_if = "WbScale::is_legacy")] + pub scale: WbScale, } impl Default for WhiteBalance { fn default() -> Self { - Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 } + // (`Legacy`, like an absent field: a default photo's settings JSON stays what it was; the engine marks the + // scale when white balance is written on a raw that has one, `lightcraft_pipeline::local::normalize_wb`) + Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0, scale: WbScale::Legacy } } } diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index e14b1bd65..3166d17c4 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1559,7 +1559,31 @@ mod tests { assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); - assert_eq!(info.relative_wb, !own, "{name}"); + // white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the + // spectral matrices, whichever look it got (issue #730) + assert_eq!(info.relative_wb, !own && !covered, "{name}"); + assert_eq!(info.legacy_relative_wb, !own && covered, "{name}"); + if covered && !own { + assert!( + (2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0, + "{name}: {info:?}" + ); + } + // the same as the catalog decides from the probe (format and camera) + let probe = crate::files::probe_bytes(name, &bytes).unwrap(); + let mut p = lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(1), + lightcraft_catalog::Source::File { path: name.into() }, + name, + &probe.format, + 1, + 1, + "", + ); + p.kind = probe.kind; + p.meta = probe.meta.clone(); + assert_eq!(p.relative_wb(), info.relative_wb, "{name}"); + assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}"); } } @@ -1988,15 +2012,24 @@ mod tests { .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); - for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] { + // (the GFX100S shares the GFX 100's measured sensor, so its white balance is in Kelvin through those + // matrices; the X-T2 and X-T20 have none and stay relative) + for (name, accepted, kelvin) in + [("raf-fuji-xt2-865.raf", true, false), ("raf-fuji-gfx100s-4503.raf", true, true), ("raf-fuji-xt20-compressed.raf", true, false)] + { let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; let header = crate::files::probe_bytes(name, &bytes).unwrap(); - assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}"); let (_, small) = crate::files::load_bytes(&bytes, 400).unwrap(); let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap(); assert_eq!(small.camera_tone.is_some(), accepted, "{name}"); assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit"); - assert!(small.raw && small.relative_wb && small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); + assert_eq!(header.as_shot_wb, Some((small.as_shot_temp, small.as_shot_tint)), "{name}"); + assert!(small.raw && small.relative_wb != kelvin && small.legacy_relative_wb == kelvin, "{name}: {small:?}"); + if kelvin { + assert!(small.as_shot_temp != 6500.0 && (2000.0..50000.0).contains(&small.as_shot_temp), "{name}: {}", small.as_shot_temp); + } else { + assert!(small.as_shot_temp == 6500.0 && small.as_shot_tint == 0.0, "{name}"); + } } } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 2b1b368b2..c1e0db658 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -229,6 +229,14 @@ pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog: ops } +/// The op that gives a raw the as-shot white balance its file reads now (`info`, a fresh probe), or none when the +/// catalog already has it or the photo is not a raw edited in Kelvin. A raw imported before its camera's matrices +/// gave it a Kelvin scale keeps the relative scale's 6500 / 0 until a decode or Reload reads the file (issue #730). +pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Option { + let read = info.as_shot_wb.filter(|(t, tint)| t.is_finite() && tint.is_finite())?; + (ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) }) +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { @@ -248,14 +256,16 @@ fn reload(s: &mut Session, p: &Value) -> Result { // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; // nothing already set is overwritten let meta_ops = fill_missing_meta(id, ph, &info); - let mut mine: Vec = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect(); + let mut mine: Vec = + content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect(); if !mine.is_empty() { ops.append(&mut mine); reloaded.push(id); } // virtual copies share the file for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { - let theirs: Vec = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect(); + let theirs: Vec = + content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect(); if !theirs.is_empty() { ops.extend(theirs); reloaded.push(c.id); diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 33570f47b..88ec4bc25 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -168,9 +168,12 @@ pub fn specs() -> Vec { } let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() }; let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| { - if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") { - d.wb.temp = info.as_shot_temp; - d.wb.tint = info.as_shot_tint; + if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") { + lightcraft_pipeline::local::normalize_wb(d, info); + if d.wb.mode == WbMode::AsShot { + d.wb.temp = info.as_shot_temp; + d.wb.tint = info.as_shot_tint; + } } for (k, v) in &vals { controls::set(d, k, *v); @@ -209,6 +212,7 @@ pub fn specs() -> Vec { let info = s.source_info(active(s, "develop.adjust")?); edit(s, "develop.adjust", spec.label, |d| { if c == "wb.temp" || c == "wb.tint" { + lightcraft_pipeline::local::normalize_wb(d, &info); if d.wb.mode == WbMode::AsShot { d.wb.temp = info.as_shot_temp; d.wb.tint = info.as_shot_tint; @@ -240,10 +244,17 @@ pub fn specs() -> Vec { Some(l) => (*l).clone(), None => { let info = s.source_info(id); - DevelopSettings { - wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint }, + let mut fresh = DevelopSettings { + wb: lightcraft_develop::WhiteBalance { + mode: WbMode::AsShot, + temp: info.as_shot_temp, + tint: info.as_shot_tint, + ..Default::default() + }, ..Default::default() - } + }; + lightcraft_pipeline::local::normalize_wb(&mut fresh, &info); + fresh } }; // a fresh start renders with the current process (a section or slider reset doesn't change it) @@ -377,7 +388,7 @@ pub fn specs() -> Vec { m => m .preset() .map(|(t, ti)| { - // presets are relative to daylight for rendered files + // presets are Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere if info.raw && !info.relative_wb { (t, ti) } else { (6500.0 * t / 5500.0, ti) } }) .unwrap_or((info.as_shot_temp, info.as_shot_tint)), @@ -387,6 +398,7 @@ pub fn specs() -> Vec { // The resolved temperature/tint is stored, so rendering never depends on the mode; the // mode is kept for the UI's WB dropdown. edit(s, "develop.wb", "White Balance", |d| { + lightcraft_pipeline::local::normalize_wb(d, &info); d.wb.mode = mode; d.wb.temp = t.clamp(2000.0, 50000.0); d.wb.tint = tint.clamp(-150.0, 150.0); @@ -416,6 +428,7 @@ pub fn specs() -> Vec { let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc); let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info); edit(s, "develop.wbPick", "White Balance", |d| { + lightcraft_pipeline::local::normalize_wb(d, &info); d.wb.mode = WbMode::Custom; d.wb.temp = t; d.wb.tint = tint; @@ -847,10 +860,14 @@ pub fn specs() -> Vec { .filter_map(|id| s.develop_of(id).map(|d| (id, d))) .filter_map(|(id, d)| { let mut nd = (*d).clone(); - if nd.wb.mode == WbMode::AsShot && (ctl == "wb.temp" || ctl == "wb.tint") { + if ctl == "wb.temp" || ctl == "wb.tint" { + // each photo on its own scale: Kelvin (an old relative value converted first) or relative let info = s.source_info(id); - nd.wb.temp = info.as_shot_temp; - nd.wb.tint = info.as_shot_tint; + lightcraft_pipeline::local::normalize_wb(&mut nd, &info); + if nd.wb.mode == WbMode::AsShot { + nd.wb.temp = info.as_shot_temp; + nd.wb.tint = info.as_shot_tint; + } } let v = controls::get(&nd, &ctl).unwrap_or(spec.default); controls::set(&mut nd, &ctl, v + delta); diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 50ccb4bd6..6094422f9 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -126,7 +126,7 @@ fn build(s: &mut Session, p: &Value) -> Result { /// One photo's smart preview to build or discard: (photo, its file in the smart previews folder, /// how to load the original at preview size, whether the photo takes the per-file camera look — -/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::relative_wb`]: only those +/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those /// proxies go stale when the fit changes). #[cfg(not(target_arch = "wasm32"))] type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option, bool); @@ -272,7 +272,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { } let path = dir.join(crate::smart::file_name(&ph)); let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview)); - jobs.push((id, path, source, ph.relative_wb())); + jobs.push((id, path, source, ph.fitted_look())); } let custom = s.smart_previews_dir.is_some(); if background { @@ -314,7 +314,7 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.relative_wb()).collect(); + let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect(); let jobs: Vec = photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect(); std::thread::Builder::new() diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 86c26c69d..b7e3aca4a 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -19,10 +19,10 @@ use serde_json::{Map, Value, json}; pub type Props = BTreeMap>; /// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw -/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its -/// as-shot look (rendered files, and raws whose readers have no illuminant, -/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white -/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510). +/// with a Kelvin scale (colour matrices in the file, or measured for its camera) takes `Temperature` +/// as Kelvin; everything developed relative to its as-shot look (rendered files, and raws without +/// matrices anywhere, [`lightcraft_catalog::relative_wb_camera`]) needs a shift from the other app's +/// as-shot white instead, because on that scale 6500 K / 0 means *as shot* (issue #510). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Target { /// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale. @@ -30,8 +30,8 @@ pub enum Target { /// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our /// relative scale. Rendered, - /// A raw developed with an absolute white balance (DNG and other files with a measured - /// illuminant): Kelvin as written. + /// A raw developed with an absolute white balance (DNG and other files with matrices, and raws + /// whose camera has measured matrices): Kelvin as written. RawAbsolute, /// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]). RawRelative, @@ -39,11 +39,12 @@ pub enum Target { impl Target { /// The target for a file of this media kind and format (its extension or Lightroom's - /// `fileFormat` name); `preview_only` for a raw that can't be decoded yet. - pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target { + /// `fileFormat` name) shot on `camera` (Exif make and model as the catalog keeps them, or + /// empty when unknown); `preview_only` for a raw that can't be decoded yet. + pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool, camera: &str) -> Target { if kind != lightcraft_catalog::MediaKind::Raw || preview_only { Target::Rendered - } else if lightcraft_catalog::relative_wb_format(format) { + } else if lightcraft_catalog::relative_wb_camera(format, camera) { Target::RawRelative } else { Target::RawAbsolute @@ -51,7 +52,7 @@ impl Target { } /// The target for a photo in the catalog. pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target { - Target::for_file(p.kind, &p.format, p.preview_only.is_some()) + Target::for_file(p.kind, &p.format, p.preview_only.is_some(), &p.meta.camera) } } @@ -272,6 +273,11 @@ pub fn to_partial(props: &Props, target: Target) -> Value { if let Some(tint) = tint { put(o, "wb.tint", json!(tint)); } + // Kelvin as the other app wrote it: say so, so the value stays Kelvin on a raw with that scale (a + // preset's Kelvin is read as the relative scale on the files that have only that, as before) + if wb == abs && matches!(target, Target::RawAbsolute | Target::Any) { + put(o, "wb.scale", json!("kelvin")); + } } // a custom Kelvin with nothing to shift it from: read on the relative scale it would be a // large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so @@ -643,6 +649,10 @@ mod tests { assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); + // Kelvin read as Kelvin says so, so it stays Kelvin on a raw with that scale; a shift does not + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["scale"], "kelvin"); + assert_eq!(to_partial(&p, Target::Any)["wb"]["scale"], "kelvin"); + assert!(rel["wb"].get("scale").is_none() && to_partial(&p, Target::Rendered)["wb"].get("scale").is_none()); // the as-shot fields are a reference, not an adjustment: nothing to report assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); // no tint next to the as-shot tint: the custom tint is taken as the shift itself @@ -674,14 +684,50 @@ mod tests { #[test] fn target_follows_the_file() { use lightcraft_catalog::MediaKind; - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, ""), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative); // Lightroom's `fileFormat` name for proprietary raws - assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative); - assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute); // a raw shown from its embedded preview develops like a rendered file - assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered); - assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Image, "JPG", false, "SONY ILCE-7M3"), Target::Rendered); + // a camera with measured matrices edits in Kelvin (issue #730), under any of its names + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false, "ILCE-7M3"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "NEF", false, "NIKON CORPORATION NIKON D850"), Target::RawAbsolute); + assert_eq!(Target::for_file(MediaKind::Raw, "CR2", false, "Canon Canon EOS 5D Mark III"), Target::RawAbsolute); + let mut p = + lightcraft_catalog::Photo::new(lightcraft_catalog::PhotoId(1), lightcraft_catalog::Source::Demo { scene: 0 }, "a.arw", "ARW", 1, 1, ""); + p.kind = MediaKind::Raw; + assert_eq!(Target::for_photo(&p), Target::RawRelative); + p.meta.camera = "SONY ILCE-7M3".into(); + assert_eq!(Target::for_photo(&p), Target::RawAbsolute); + } + + /// Lightroom writes `crs:Temperature` as Kelvin for a raw whose camera LightCraft has matrices for: the value + /// is read as written, marked Kelvin, and the as-shot reference is not needed (issue #730); the same packet on + /// a raw without matrices still takes the #510 shift. + #[test] + fn kelvin_is_direct_on_raws_whose_camera_has_matrices() { + use lightcraft_catalog::MediaKind; + let x = r#""#; + let p = props(x); + let a7m3 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M3"); + assert_eq!(to_partial(&p, a7m3)["wb"], json!({"mode": "custom", "temp": 4650.0, "tint": 3.0, "scale": "kelvin"})); + let a7m2 = Target::for_file(MediaKind::Raw, "ARW", false, "SONY ILCE-7M2"); + let shifted = to_partial(&p, a7m2); + assert!(shifted["wb"]["temp"].as_f64().is_some_and(|t| t > 5000.0 && t < 6500.0), "{shifted}"); + assert_eq!(shifted["wb"]["tint"], 4.0); + assert!(shifted["wb"].get("scale").is_none()); + // without the reference the Kelvin camera still reads it, the other stays As Shot + let bare = props(&x.replace(r#" crs:AsShotTemperature="5400" crs:AsShotTint="-1""#, "")); + assert_eq!(to_partial(&bare, a7m3)["wb"]["temp"], 4650.0); + assert_eq!(to_partial(&bare, a7m2)["wb"], json!({"mode": "asShot"})); + // a named preset carries no numbers either way + let daylight = props(&x.replace(r#"crs:WhiteBalance="Custom""#, r#"crs:WhiteBalance="Daylight""#)); + assert_eq!(to_partial(&daylight, a7m3)["wb"], json!({"mode": "daylight"})); } #[test] diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 31253f66a..604cc96b9 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -131,10 +131,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } - let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw)); - // Vendor RGB multipliers do not identify an absolute illuminant without camera calibration. - let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color); - let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) }); + let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw))); let embedded_lens = embedded_lens(&raw); return Ok(ProbeInfo { embedded_lens, @@ -367,8 +365,9 @@ fn load_bytes_now( let info = raw.info(); let lens = embedded_lens(&info); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - // Without colour matrices of its own, white balance is relative to the as-shot look (`Photo::relative_wb`). - let own_matrix = lightcraft_raw::color::has_matrix(&raw.color); + // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or + // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) + let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); drop(bytes); @@ -435,17 +434,17 @@ fn load_bytes_now( eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", ")); } let twin = twin.map(|picture| finish(picture).0); - let (temp, tint) = xy_to_temp_tint(xy); - let relative = crate::camera_preview::file_local_look(raw.format) && !own_matrix; - // White balance re-evaluates the file's own colour model (when it has one and no - // file-local look matrix sits on top of it) - let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| { + // White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits + // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the + // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale + // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| { let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let local_tone = local_tone(&raw); - let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) }; + let (temp, tint) = scale.as_shot(xy); return Ok(( img, twin, @@ -454,7 +453,8 @@ fn load_bytes_now( as_shot_temp: temp, as_shot_tint: tint, lens, - relative_wb: relative, + relative_wb: scale.relative, + legacy_relative_wb: scale.legacy_relative, camera_color, camera_tone, mattes, @@ -469,6 +469,47 @@ fn load_bytes_now( Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default())) } +/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from +/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step). +/// +/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without +/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured +/// (`lightcraft_raw::spectral`): they turn its as-shot multipliers into Kelvin and tint, nothing else — the look +/// stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). +struct WbScaleOf { + own_matrix: bool, + relative: bool, + /// Kelvin through the camera's measured matrices: edited on the relative scale before those were used. + legacy_relative: bool, + /// The as-shot white through the measured matrices, when that is where the scale comes from. + spectral_white: Option, +} + +impl WbScaleOf { + fn new( + format: lightcraft_raw::RawFormat, + color: &lightcraft_raw::ColorData, + wb_multipliers: Option<[f32; 3]>, + meta: &lightcraft_meta::Metadata, + ) -> Self { + let own_matrix = lightcraft_raw::color::has_matrix(color); + let fitted = crate::camera_preview::file_local_look(format) && !own_matrix; + let camera = fitted.then(|| meta.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model))).flatten(); + let spectral_white = camera.and_then(|c| lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers)); + WbScaleOf { own_matrix, relative: fitted && camera.is_none(), legacy_relative: fitted && camera.is_some(), spectral_white } + } + /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through + /// the measured matrices, else `own` (the white the file's own tags give). Whole numbers, as the catalog + /// keeps them. A covered camera whose file names no white (no multipliers) falls back to `own`, D65. + fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) { + if self.relative { + return (6500.0, 0.0); + } + let (t, tint) = xy_to_temp_tint(self.spectral_white.unwrap_or(own)); + (t.round(), tint.round()) + } +} + /// The raw's gain table map with where its developed picture (the default crop, oriented) sits in /// the active area: rendered when the photo's "Camera local tone mapping" option is on. fn local_tone(raw: &lightcraft_raw::RawImage) -> Option> { @@ -689,6 +730,45 @@ mod tests { use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + /// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a + /// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot + /// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the + /// format and camera string agrees. + #[test] + fn white_balance_scale_follows_the_files_matrices_then_the_cameras() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let mul = Some([2.2f32, 1.0, 1.6]); + let d65 = lightcraft_color::D65; + // a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe + let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3")); + assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.spectral_white.is_some()); + let (t, tint) = a7m3.as_shot(d65); + assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}"); + assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3")); + // the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65) + let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3")); + assert!(bare.legacy_relative && bare.spectral_white.is_none()); + assert_eq!(bare.as_shot(d65), { + let (t, tint) = xy_to_temp_tint(d65); + (t.round(), tint.round()) + }); + // a camera without measured matrices: relative, whatever the multipliers + let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2")); + assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.spectral_white.is_none()); + assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0)); + assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2")); + let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default()); + assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0)); + // a file with matrices of its own (DNG): absolute from them, no matter whose camera + let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() }; + let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3")); + assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.spectral_white.is_none()); + let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0); + assert_eq!(dng.as_shot(xy), (4200.0, 12.0)); + assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); + } + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated /// file or one that isn't an image. diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 29ea013e3..74c1bba87 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -912,7 +912,7 @@ impl ImportJob { let packet = crate::sidecar::find_sidecar(&path, self.naming) .and_then(|f| std::fs::read_to_string(f).ok()) .or_else(|| info.xmp.clone().filter(|_| raw)); - let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some()); + let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { Ok(sc) => Some(sc), Err(e) => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66acfe4a7..bb751033d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -471,8 +471,8 @@ impl Session { } /// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background - /// scan): journaled like any op, but not an undo step, and it leaves the redo stack alone. - #[cfg(not(target_arch = "wasm32"))] + /// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the + /// redo stack alone. pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> { let fwd = op.clone(); self.catalog.apply(op)?; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 6d5b548f1..4659ddb0c 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -19,7 +19,7 @@ use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Weak}; -use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source}; +use lightcraft_catalog::{MediaKind, Op, Photo, PhotoId, Source}; use lightcraft_develop::DevelopSettings; use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache}; use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache}; @@ -940,8 +940,18 @@ pub fn source_info(p: &Photo) -> SourceInfo { // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white // balance and the display tone curve, like any other rendered file. if p.develops_raw() { - let (temp, tint) = if p.relative_wb() { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; - SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() } + let relative = p.relative_wb(); + let (temp, tint) = if relative { (6500.0, 0.0) } else { p.as_shot_wb.unwrap_or((5500.0, 0.0)) }; + SourceInfo { + raw: true, + as_shot_temp: temp, + as_shot_tint: tint, + lens: p.embedded_lens, + relative_wb: relative, + // (a fitted-look raw edited in Kelvin: its camera's matrices give the scale, see `files::WbScaleOf`) + legacy_relative_wb: p.fitted_look() && !relative, + ..Default::default() + } } else { SourceInfo::default() } @@ -1254,15 +1264,38 @@ impl crate::Session { }) } - /// Accept a finished job's loaded source into the cache. + /// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog + /// has wrong is corrected too ([`Self::learn_as_shot_wb`]). pub fn accept(&mut self, r: &RenderResult) { if let Some(src) = &r.loaded && r.source_key.is_none_or(|key| self.catalog.photo(r.photo).is_some_and(|p| Hasher128::new().str(&content_key(p)).finish() == key)) { + if let Some(info) = &src.info { + self.learn_as_shot_wb(r.photo, info); + } self.media.insert_source(r.photo, r.level, src.clone()); } } + /// Record the as-shot white balance a decode of photo `id`'s file read (`info`) when the catalog has another: + /// a raw imported before its camera's matrices gave it a Kelvin scale keeps 6500 / 0 (the relative scale's + /// reference) until its file is next decoded (issue #730). Bookkeeping, not an undo step; the look does not + /// change (the pipeline reads the stored settings against the real as-shot white either way), only what As Shot + /// says and what a smart preview renders with. Only for raws edited in Kelvin. + fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { + let Some(p) = self.catalog.photo(id) else { return }; + if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) { + return; + } + let read = (info.as_shot_temp, info.as_shot_tint); + if !(read.0.is_finite() && read.1.is_finite()) || p.as_shot_wb == Some(read) { + return; + } + if let Err(e) = self.apply_system(Op::SetAsShotWb { id, wb: Some(read) }) { + log::warn!("photo {}: as-shot white balance not recorded: {e}", id.0); + } + } + /// Synchronous render (CLI, MCP, tests). pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; @@ -1322,6 +1355,9 @@ impl crate::Session { let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); let r = self.media.source_ref(&p, level).load_source()?; let image = r.image.clone(); + if let Some(info) = &r.info { + self.learn_as_shot_wb(id, info); + } self.media.insert_source(id, level, r); Ok(image) } @@ -1356,7 +1392,6 @@ pub type FileProbe = Arc Result + Send + Sync #[cfg(test)] mod thumbnail_hash_tests { use super::*; - use lightcraft_catalog::Op; fn session() -> crate::Session { let mut s = crate::Session::new(); @@ -1504,6 +1539,81 @@ mod tests { assert!(s.media.get_source(id, r.level).is_none()); } + /// Issue #730: an ILCE-7M3 ARW from an older catalog (white balance relative to the as-shot look, 6500 / 0 as + /// its as-shot white, a Custom value on that scale) renders pixel for pixel as it did once the camera's matrices + /// give it a Kelvin scale; the decode teaches the catalog the real as-shot white (bookkeeping, not an undo step), + /// and the panel then reads As Shot in Kelvin while the stored settings keep their look. + #[test] + fn old_relative_white_balance_on_a_kelvin_camera_keeps_its_look_and_learns_the_as_shot_white() { + use lightcraft_develop::{WbMode, WbScale}; + let mut s = crate::Session::new(); + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: "/photos/DSC00001.ARW".into() }, "DSC00001.ARW", "ARW", 64, 64, "2026-01-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = "SONY ILCE-7M3".into(); + p.as_shot_wb = Some((6500.0, 0.0)); + let mut d = lightcraft_develop::DevelopSettings::for_raw(6500.0, 0.0); + (d.wb.mode, d.wb.temp, d.wb.tint) = (WbMode::Custom, 5800.0, 3.0); + assert_eq!(d.wb.scale, WbScale::Legacy); + p.develop = Arc::new(d.clone()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.selection.ids = vec![id]; + s.selection.active = Some(id); + // the catalog's view of the source before the file is read: Kelvin scale, as-shot white not yet known + let header = s.source_info(id); + assert!(header.raw && !header.relative_wb && header.legacy_relative_wb); + assert_eq!((header.as_shot_temp, header.as_shot_tint), (6500.0, 0.0)); + // the decode: what `files::load_bytes` reports for this camera (the as-shot white through its matrices) + let decoded = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.0, ..Default::default() }; + let mut pixels = Rgb32f::new(64, 64); + for (i, px) in pixels.data.iter_mut().enumerate() { + let v = (i % 64) as f32 / 64.0; + *px = [0.05 + 0.6 * v, 0.04 + 0.4 * v, 0.03 + 0.3 * v]; + } + let mut job = s.render_job(id, 64, 64, false, true).unwrap(); + let (loaded_info, loaded_pixels) = (decoded.clone(), pixels.clone()); + job.source = SourceRef::File { + path: "/photos/DSC00001.ARW".into(), + max_edge: 64, + loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))), + fallback: None, + denoise: None, + }; + let undo_before = s.undo.len(); + let request = job.request; + let r = job.run(); + let new = r.rendered.as_ref().unwrap().image.data.clone(); + // the old pipeline: the same settings against the relative-scale source facts of an older build + let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; + let old = lightcraft_pipeline::render(&pixels, &old_info, &d, &request).image.data; + assert_eq!(new, old, "the look of an existing edit changed"); + assert_ne!( + new, + lightcraft_pipeline::render(&pixels, &decoded, &lightcraft_develop::DevelopSettings::for_raw(4986.0, -2.0), &request).image.data + ); + // accepting the result records the as-shot white, journaled but not undoable, and the settings are untouched + s.accept(&r); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.as_shot_wb, Some((4986.0, -2.0))); + assert_eq!(*p.develop, d); + assert_eq!(s.undo.len(), undo_before); + assert!(s.pending_log.iter().any(|op| matches!(op, Op::SetAsShotWb { id: i, wb: Some((4986.0, -2.0)) } if *i == id))); + assert!(!p.is_edited() || p.develop.wb.mode == WbMode::Custom); + // the panel reads the white balance in effect in Kelvin: the value the relative edit renders as + let info = s.source_info(id); + assert_eq!((info.as_shot_temp, info.as_shot_tint), (4986.0, -2.0)); + let shown = lightcraft_pipeline::local::effective_wb(&info, &d); + assert_eq!(shown, lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0))); + assert!(shown.0 < 4986.0 && shown.0 > 4000.0, "{shown:?}"); + // accepting again changes nothing more + let log = s.pending_log.len(); + s.accept(&r); + assert_eq!(s.pending_log.len(), log); + // a catalog-only (proxy) render of the same settings before the decode gave the same pixels too + let proxy = lightcraft_pipeline::render(&pixels, &header, &d, &request).image.data; + assert_eq!(proxy, old, "a smart preview of an existing edit changed"); + } + #[test] fn budget_evicts_least_recently_used_across_caches() { let img = |w: usize| Arc::new(Rgb32f::new(w, w)); diff --git a/crates/engine/src/tests_color.rs b/crates/engine/src/tests_color.rs index e765e0671..e526d4c47 100644 --- a/crates/engine/src/tests_color.rs +++ b/crates/engine/src/tests_color.rs @@ -121,3 +121,102 @@ fn targeted_adjustment_on_curve_and_mixer() { assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0)); assert!(s.execute("develop.targeted", &json!({"target": "nope", "x": 0.5, "y": 0.5, "delta": 1})).is_err()); } + +/// Issue #730: on a raw whose camera has measured colour matrices the white-balance commands write Kelvin, mark +/// the scale, resolve the presets to their real temperatures, and turn a value still on the old relative scale into +/// the Custom Kelvin value that renders the same before applying an edit on top of it. +#[test] +fn white_balance_commands_write_kelvin_on_raws_whose_camera_has_matrices() { + use lightcraft_catalog::{MediaKind, Op, Photo, Source}; + use lightcraft_develop::{DevelopSettings, WbMode, WbScale}; + let mut s = Session::new(); + let add = |s: &mut Session, name: &str, camera: &str, as_shot: (f64, f64)| { + let id = s.catalog.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: format!("/photos/{name}") }, name, "ARW", 64, 64, "2026-01-01T00:00:00"); + p.kind = MediaKind::Raw; + p.meta.camera = camera.into(); + p.as_shot_wb = Some(as_shot); + p.develop = std::sync::Arc::new(p.camera_defaults()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let kelvin = add(&mut s, "a.arw", "SONY ILCE-7M3", (4986.0, -2.0)); + let relative = add(&mut s, "b.arw", "SONY ILCE-7M2", (6500.0, 0.0)); + let dev = |s: &Session, id| (*s.develop_of(id).unwrap()).clone(); + assert_eq!((dev(&s, kelvin).wb.scale, dev(&s, relative).wb.scale), (WbScale::Kelvin, WbScale::Legacy)); + + // presets are real temperatures on the Kelvin raw, scaled around 6500 K = as shot on the other + s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap(); + let r = s.execute("develop.wb", &json!({"mode": "cloudy"})).unwrap(); + assert_eq!((r["temp"].as_f64(), r["tint"].as_f64()), (Some(6500.0), Some(10.0))); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Cloudy, 6500.0, 10.0, WbScale::Kelvin)); + s.execute("develop.wb", &json!({"mode": "tungsten"})).unwrap(); + assert_eq!(dev(&s, kelvin).wb.temp, 2850.0); + s.execute("library.select", &json!({"ids": [relative.0], "active": relative.0})).unwrap(); + let r = s.execute("develop.wb", &json!({"mode": "cloudy"})).unwrap(); + assert!((r["temp"].as_f64().unwrap() - 6500.0 * 6500.0 / 5500.0).abs() < 1e-6, "{r}"); + assert_eq!(dev(&s, relative).wb.scale, WbScale::Legacy); + + // a slider edit on the Kelvin raw is Kelvin; the stored value says so + s.execute("library.select", &json!({"ids": [kelvin.0], "active": kelvin.0})).unwrap(); + s.execute("develop.wb", &json!({"mode": "asShot"})).unwrap(); + s.execute("develop.set", &json!({"control": "wb.temp", "value": 5200})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5200.0, -2.0, WbScale::Kelvin)); + let info = s.source_info(kelvin); + assert_eq!(lightcraft_engine_effective(&info, &d), (5200.0, -2.0)); + + // settings from an older catalog on the Kelvin raw: a relative Custom value (6500 = as shot) with no scale + let mut old = DevelopSettings::for_raw(6500.0, 0.0); + (old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Custom, 5800.0, 3.0); + s.set_develop(kelvin, old.clone(), "old").unwrap(); + let shown = lightcraft_engine_effective(&info, &old); + let expect = lightcraft_pipeline::local::legacy_to_kelvin(5800.0, 3.0, (4986.0, -2.0)); + assert_eq!(shown, expect); + assert!(shown.0 < 4986.0, "a warm relative shift is a lower Kelvin than as shot: {shown:?}"); + // nudging the tint converts the temperature to the Kelvin it rendered as, then applies the nudge + s.execute("develop.adjust", &json!({"control": "wb.tint", "delta": 5})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin)); + assert!((d.wb.temp - expect.0).abs() < 1e-6 && (d.wb.tint - (expect.1 + 5.0)).abs() < 1e-6, "{:?} vs {expect:?}", d.wb); + // a relative preset value from an older catalog becomes a Custom value with its old look, not the preset's Kelvin + let mut old = DevelopSettings::for_raw(6500.0, 0.0); + (old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Cloudy, 6500.0 * 6500.0 / 5500.0, 10.0); + s.set_develop(kelvin, old.clone(), "old preset").unwrap(); + assert_eq!(lightcraft_engine_effective(&info, &old), lightcraft_pipeline::local::legacy_to_kelvin(old.wb.temp, 10.0, (4986.0, -2.0))); + s.execute("develop.set", &json!({"control": "wb.tint", "value": 0})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.tint, d.wb.scale), (WbMode::Custom, 0.0, WbScale::Kelvin)); + assert!(d.wb.temp != 6500.0 && (d.wb.temp - lightcraft_pipeline::local::legacy_to_kelvin(old.wb.temp, 10.0, (4986.0, -2.0)).0).abs() < 1e-6); + // a partial without a scale (an old preset, `develop.merge`) keeps the relative meaning on the Kelvin raw + s.execute("develop.merge", &json!({"settings": {"wb": {"mode": "custom", "temp": 6500.0, "tint": 0.0}}})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!(d.wb.scale, WbScale::Legacy); + assert_eq!(lightcraft_engine_effective(&info, &d), (4986.0, -2.0), "6500 / 0 without a scale is as shot"); + // Quick Develop on old settings: As Shot from an older catalog (no scale) nudges the Kelvin as-shot white, a + // relative Custom value is converted to the Kelvin it rendered as first (never a Kelvin number read as relative) + let mut old = DevelopSettings::for_raw(6500.0, 0.0); + old.wb.mode = WbMode::AsShot; + s.set_develop(kelvin, old, "old as shot").unwrap(); + s.execute("develop.quickAdjust", &json!({"control": "wb.temp", "delta": 100})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.temp, d.wb.tint, d.wb.scale), (WbMode::Custom, 5086.0, -2.0, WbScale::Kelvin)); + let mut old = DevelopSettings::for_raw(6500.0, 0.0); + (old.wb.mode, old.wb.temp, old.wb.tint) = (WbMode::Custom, 5800.0, 3.0); + s.set_develop(kelvin, old, "old custom").unwrap(); + s.execute("develop.quickAdjust", &json!({"control": "wb.tint", "delta": 5})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.scale), (WbMode::Custom, WbScale::Kelvin)); + assert!((d.wb.temp - expect.0).abs() < 1e-6 && (d.wb.tint - (expect.1 + 5.0)).abs() < 1e-6, "{:?} vs {expect:?}", d.wb); + + // reset: As Shot, on the Kelvin scale + s.execute("develop.reset", &json!({})).unwrap(); + let d = dev(&s, kelvin); + assert_eq!((d.wb.mode, d.wb.scale), (WbMode::AsShot, WbScale::Kelvin)); + assert!(!s.catalog.photo(kelvin).unwrap().is_edited()); +} + +fn lightcraft_engine_effective(info: &lightcraft_pipeline::SourceInfo, d: &lightcraft_develop::DevelopSettings) -> (f64, f64) { + lightcraft_pipeline::local::effective_wb(info, d) +} diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index f7c33ee9e..c8501addf 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -78,6 +78,10 @@ pub struct SourceInfo { pub as_shot_tint: f64, /// No measured camera illuminant: WB adjustments are relative to the camera's rendered look. pub relative_wb: bool, + /// The Kelvin scale comes from matrices measured for the camera model, not from the file: before those were + /// used for white balance, this source was edited on the relative scale (6500 K / 0 = as shot), so settings + /// without a [`lightcraft_develop::WbScale`] are read that way ([`local::legacy_to_kelvin`]). + pub legacy_relative_wb: bool, /// The camera's colour model (raw files with a colour matrix); `None`: white balance adapts /// the developed pixels (Bradford, linear Rec.2020). pub camera_color: Option>, @@ -97,6 +101,7 @@ impl Default for SourceInfo { as_shot_tint: 0.0, lens: None, relative_wb: false, + legacy_relative_wb: false, camera_color: None, camera_tone: None, mattes: None, diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index fd7fefd34..41683e5a7 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -1,7 +1,7 @@ //! Scene-linear preparation: white balance + exposure, and the spatial planes the per-pixel stage //! needs (edge-aware base layer for highlights/shadows, clarity/texture bands, dehaze veil). -use lightcraft_color::cct::{temp_tint_to_xy, wb_matrix}; +use lightcraft_color::cct::{temp_tint_to_xy, wb_matrix, xy_to_temp_tint}; use lightcraft_color::{REC2020, luminance_2020}; use lightcraft_develop::DevelopSettings; use lightcraft_raster::blur::gaussian; @@ -37,7 +37,7 @@ pub fn wb_matrix_for(info: &SourceInfo, s: &DevelopSettings) -> Option<[[f32; 3] { return Some(m.to_f32()); } - let set = wb_matrix(&REC2020, temp_tint_to_xy(t, tint)); + let set = wb_matrix(&REC2020, effective_white(info, s)); let shot = wb_matrix(&REC2020, temp_tint_to_xy(info.as_shot_temp, info.as_shot_tint)); let m = set.mul(&shot.inverse().unwrap_or(lightcraft_color::Mat3::IDENTITY)); // Normalize so neutral luminance is preserved (WB shouldn't change exposure). @@ -64,16 +64,102 @@ fn wb_gain(img: &mut Rgb32f, info: &SourceInfo, s: &DevelopSettings, gain: f32) }); } -/// The white balance actually in effect (presets resolve to their Kelvin values for raw files). +/// The white balance actually in effect (presets resolve to their Kelvin values for raw files; settings written on +/// the relative scale before a raw's camera matrices reached its white balance give the same colour change on the +/// Kelvin scale, see [`legacy_to_kelvin`]). pub fn effective_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) { use lightcraft_develop::WbMode; match s.wb.mode { WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint), + _ if legacy_wb(info, s) => legacy_to_kelvin(s.wb.temp, s.wb.tint, (info.as_shot_temp, info.as_shot_tint)), m if info.raw && !info.relative_wb => m.preset().unwrap_or((s.wb.temp, s.wb.tint)), _ => (s.wb.temp, s.wb.tint), } } +/// The source white the render adapts from the as-shot white to: [`effective_wb`] as a chromaticity, except that a +/// white balance still on the relative scale is composed exactly ([`legacy_white`]) rather than read back through +/// its rounded Kelvin / tint. +fn effective_white(info: &SourceInfo, s: &DevelopSettings) -> lightcraft_color::Xy { + if legacy_wb(info, s) + && s.wb.mode != lightcraft_develop::WbMode::AsShot + && let Some(xy) = legacy_white(s.wb.temp, s.wb.tint, (info.as_shot_temp, info.as_shot_tint)) + { + return xy; + } + let (t, tint) = effective_wb(info, s); + temp_tint_to_xy(t, tint) +} + +/// Whether `s` holds a white balance written on the relative scale for a source that is edited in Kelvin today. +fn legacy_wb(info: &SourceInfo, s: &DevelopSettings) -> bool { + info.raw && !info.relative_wb && info.legacy_relative_wb && s.wb.scale == lightcraft_develop::WbScale::Legacy +} + +/// The white that renders exactly as `(temp, tint)` did on the relative scale, where 6500 K / 0 meant *as shot* and +/// the source's real as-shot white is `as_shot`. +/// +/// The relative render adapted the picture from 6500 K / 0 to `(temp, tint)` (Bradford, [`wb_matrix_for`]); the +/// Kelvin render adapts it from `as_shot` to the result. Bradford adaptations are von Kries scalings in one cone +/// space, so they compose exactly: the new white's cone response is the old target's times the as-shot white's +/// over the old reference's, and the two renders give the same matrix (up to the scale the luminance +/// normalisation removes). `None` when the arithmetic has nowhere to go (a non-finite input, a white outside the +/// chromaticity plane). +pub fn legacy_white(temp: f64, tint: f64, as_shot: (f64, f64)) -> Option { + use lightcraft_color::{BRADFORD, Xy}; + if !(temp.is_finite() && tint.is_finite() && as_shot.0.is_finite() && as_shot.1.is_finite()) { + return None; + } + let cones = |t: f64, tint: f64| BRADFORD.apply(temp_tint_to_xy(t, tint).to_xyz()); + let (old, reference, shot) = (cones(temp, tint), cones(6500.0, 0.0), cones(as_shot.0, as_shot.1)); + let inverse = BRADFORD.inverse()?; + let mut new = [0.0; 3]; + for i in 0..3 { + if reference[i].abs() < 1e-12 { + return None; + } + new[i] = old[i] * shot[i] / reference[i]; + } + let xy = Xy::from_xyz(inverse.apply(new)); + (xy.x.is_finite() && xy.y.is_finite() && xy.y > 0.0).then_some(xy) +} + +/// The Kelvin / tint of [`legacy_white`]: what a white balance written on the relative scale reads as, and becomes +/// when it is next edited ([`normalize_wb`]). Exact wherever the white has a Kelvin value (1000–50000 K after the +/// composition; the relative slider reached 2000–13550 K around 6500 K, so only a far-blue as-shot white pushed +/// to the slider's blue end lands beyond, and clamps), to the precision of the Kelvin / tint parametrisation +/// (better than 0.01 % away from its 4000 K seam). Unchanged when the arithmetic has nowhere to go. +pub fn legacy_to_kelvin(temp: f64, tint: f64, as_shot: (f64, f64)) -> (f64, f64) { + if temp == 6500.0 && tint == 0.0 && as_shot.0.is_finite() && as_shot.1.is_finite() { + // the relative scale's as-shot reference, exactly (a Custom value left where As Shot was stays as shot) + return as_shot; + } + match legacy_white(temp, tint, as_shot).map(xy_to_temp_tint) { + Some((t, tint)) if t.is_finite() && tint.is_finite() => (t, tint), + _ => (temp, tint), + } +} + +/// Bring the white balance of `d` onto the scale `info` is edited on, before a new value is written into it: on a +/// raw with a Kelvin scale the values are marked Kelvin, and ones still on the relative scale become the Custom +/// Kelvin / tint that renders the same ([`legacy_to_kelvin`]; a preset mode's look is kept as that custom +/// value, since the preset's Kelvin value would be a different look). Elsewhere the scale is cleared, so values +/// edited on a rendered photo or a raw without matrices never pass for Kelvin when pasted onto one that has them. +pub fn normalize_wb(d: &mut DevelopSettings, info: &SourceInfo) { + use lightcraft_develop::{WbMode, WbScale}; + if !(info.raw && !info.relative_wb) { + d.wb.scale = WbScale::Legacy; + return; + } + if legacy_wb(info, d) && d.wb.mode != WbMode::AsShot { + let (t, tint) = legacy_to_kelvin(d.wb.temp, d.wb.tint, (info.as_shot_temp, info.as_shot_tint)); + d.wb.temp = t; + d.wb.tint = tint; + d.wb.mode = WbMode::Custom; + } + d.wb.scale = WbScale::Kelvin; +} + /// Self-guided filter (He et al.) on a single plane with Gaussian windows of `sigma` px. pub fn guided(p: &Plane, sigma: f32, eps: f32) -> Plane { let (ma, mb) = guided_coeffs(p, sigma, eps); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 185d96b31..c7c0e9ff1 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -427,6 +427,114 @@ fn tint_negative_is_green_and_positive_is_magenta() { } } +/// Settings written on the relative scale (6500 K / 0 = as shot, no `WbScale`) for a raw whose camera matrices now +/// give it a Kelvin scale render exactly as they did: the same white-balance matrix, and the same pixels, as the +/// relative render; the Kelvin / tint they convert to renders the same again (issue #730, the class of bug of #510). +#[test] +fn legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw() { + use crate::local::{effective_wb, legacy_to_kelvin, wb_matrix_for}; + use lightcraft_develop::{WbMode, WbScale}; + let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() }; + let src = Rgb32f::filled(16, 16, [0.3, 0.18, 0.09]); + // (as-shot whites the ILCE-7M3 table of issue #730 reads, and the relative slider's whole reach: −100..+100 is + // 2000..13550 K around 6500 K; a white bluer than 50000 K after the composition has no Kelvin and clamps, so + // the blue end is paired with as-shot whites up to 7000 K only) + for (as_t, as_tint) in [(4986.0, -2.4), (2707.0, -4.0), (9338.0, 8.9), (6500.0, 0.0), (3157.0, 40.0), (7000.0, -20.0)] { + let new_info = + SourceInfo { raw: true, relative_wb: false, legacy_relative_wb: true, as_shot_temp: as_t, as_shot_tint: as_tint, ..Default::default() }; + let blue_end = as_t <= 7000.0; + for (temp, tint) in [(6500.0, 0.0), (5800.0, 3.0), (7682.0, 10.0), (4000.0, -30.0), (2000.0, 150.0), (12000.0, 55.0), (13550.0, -150.0)] { + if temp > 10000.0 && !blue_end { + continue; + } + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, temp, tint); + assert_eq!(s.wb.scale, WbScale::Legacy); + let old = wb_matrix_for(&old_info, &s); + let new = wb_matrix_for(&new_info, &s); + let near = |a: Option<[[f32; 3]; 3]>, b: Option<[[f32; 3]; 3]>, tol: f32| match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() <= tol * (1.0 + x.abs())), + _ => false, + }; + // the same matrix to f32 precision: the white is composed, not read back through Kelvin / tint + assert!(near(old, new, 2e-6), "as shot {as_t}/{as_tint}, {temp}/{tint}: {old:?} vs {new:?}"); + // the converted Kelvin values, once written, render the same again to the precision of the Kelvin / + // tint parametrisation (whose seam at 4000 K costs up to 0.01 %) + let (kt, ktint) = legacy_to_kelvin(temp, tint, (as_t, as_tint)); + assert_eq!(effective_wb(&new_info, &s), (kt, ktint)); + let mut k = s.clone(); + (k.wb.temp, k.wb.tint, k.wb.scale) = (kt, ktint, WbScale::Kelvin); + assert!(near(old, wb_matrix_for(&new_info, &k), 3e-4), "as shot {as_t}/{as_tint}, {temp}/{tint} → {kt}/{ktint}"); + // and the rendered pixels agree + let a = render(&src, &old_info, &s, &RenderRequest::fit(16, 16)).image; + let b = render(&src, &new_info, &s, &RenderRequest::fit(16, 16)).image; + let c = render(&src, &new_info, &k, &RenderRequest::fit(16, 16)).image; + for (x, (y, z)) in a.data.iter().zip(b.data.iter().zip(&c.data)) { + assert_eq!(x, y, "as shot {as_t}/{as_tint}, {temp}/{tint}"); + assert!(x.iter().zip(z).all(|(p, q)| p.abs_diff(*q) <= 1), "{x:?} {z:?}"); + } + } + // as shot on the relative scale is as shot on the Kelvin scale, exactly + assert_eq!(legacy_to_kelvin(6500.0, 0.0, (as_t, as_tint)), (as_t, as_tint)); + } + // a preset stored on the relative scale keeps its old look, not the preset's Kelvin value + let new_info = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.4, ..Default::default() }; + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Cloudy, 6500.0 * 6500.0 / 5500.0, 10.0); + assert_eq!(effective_wb(&new_info, &s), legacy_to_kelvin(s.wb.temp, s.wb.tint, (4986.0, -2.4))); + s.wb.scale = WbScale::Kelvin; + assert_eq!(effective_wb(&new_info, &s), WbMode::Cloudy.preset().unwrap()); + // a raw whose file carries the matrices (DNG) never had a relative scale: its old values are Kelvin + let dng = SourceInfo { raw: true, as_shot_temp: 5100.0, as_shot_tint: 4.0, ..Default::default() }; + s.wb.scale = WbScale::Legacy; + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0); + assert_eq!(effective_wb(&dng, &s), (3200.0, 10.0)); + // hostile numbers come back unchanged + assert!(legacy_to_kelvin(f64::NAN, 0.0, (5000.0, 0.0)).0.is_nan()); + assert_eq!(legacy_to_kelvin(5000.0, f64::INFINITY, (5000.0, 0.0)).1, f64::INFINITY); + assert_eq!(legacy_to_kelvin(5000.0, 0.0, (f64::NAN, 0.0)), (5000.0, 0.0)); + assert_eq!(legacy_to_kelvin(6500.0, 0.0, (f64::NAN, 0.0)), (6500.0, 0.0)); + assert_eq!(crate::local::legacy_white(f64::NAN, 0.0, (5000.0, 0.0)), None); + let hostile = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: f64::NAN, as_shot_tint: 0.0, ..Default::default() }; + let _ = render(&Rgb32f::filled(4, 4, [0.2; 3]), &hostile, &s, &RenderRequest::fit(4, 4)); +} + +/// Before an edit, the settings' white balance is brought onto the source's scale: Kelvin raws mark it (converting +/// relative values once, into a Custom value with the same look), everything else clears it. +#[test] +fn normalize_wb_marks_kelvin_raws_and_converts_relative_values_once() { + use crate::local::{legacy_to_kelvin, normalize_wb}; + use lightcraft_develop::{WbMode, WbScale}; + let kelvin = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.4, ..Default::default() }; + // as shot: marked, values untouched + let mut s = DevelopSettings::default(); + normalize_wb(&mut s, &kelvin); + assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::AsShot, 6500.0, 0.0, WbScale::Kelvin)); + // a relative custom value: converted, Custom, marked; a second pass changes nothing + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Cloudy, 7682.0, 10.0); + normalize_wb(&mut s, &kelvin); + let (t, tint) = legacy_to_kelvin(7682.0, 10.0, (4986.0, -2.4)); + assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::Custom, t, tint, WbScale::Kelvin)); + let again = s.clone(); + normalize_wb(&mut s, &kelvin); + assert_eq!(s, again); + // a DNG's old values are Kelvin already: marked, untouched + let dng = SourceInfo { raw: true, as_shot_temp: 5100.0, as_shot_tint: 4.0, ..Default::default() }; + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0); + normalize_wb(&mut s, &dng); + assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::Custom, 3200.0, 10.0, WbScale::Kelvin)); + // a rendered photo and a raw without matrices: the scale is cleared + for info in [SourceInfo::default(), SourceInfo { raw: true, relative_wb: true, ..Default::default() }] { + let mut s = DevelopSettings::default(); + (s.wb.mode, s.wb.temp, s.wb.scale) = (WbMode::Custom, 5800.0, WbScale::Kelvin); + normalize_wb(&mut s, &info); + assert_eq!((s.wb.mode, s.wb.temp, s.wb.scale), (WbMode::Custom, 5800.0, WbScale::Legacy)); + } +} + /// A dual-illuminant (A / D65) camera colour model, its pixels developed for `temp` / `tint`. fn camera_color(temp: f64, tint: f64) -> std::sync::Arc { use lightcraft_color::Mat3; diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index d738bb1fe..a7cea813d 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -134,6 +134,11 @@ pub fn as_shot_white_xy_of(info: &crate::RawInfo) -> Xy { as_shot_white(&info.color, info.wb_multipliers) } +/// The as-shot white from colour tags and vendor multipliers alone (see [`as_shot_white_xy`]). +pub fn as_shot_white_of(c: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Xy { + as_shot_white(c, wb_multipliers) +} + fn as_shot_white(c: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Xy { if let Some(xy) = c.as_shot_white_xy { return xy; diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs index 00f41b554..cfa99ff5d 100644 --- a/crates/raw/src/spectral.rs +++ b/crates/raw/src/spectral.rs @@ -8,7 +8,7 @@ //! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice //! (`docs/camera-preview-colour.md`). use crate::ColorData; -use lightcraft_color::Mat3; +use lightcraft_color::{Mat3, Xy}; #[path = "spectral_table.rs"] mod table; @@ -76,6 +76,34 @@ pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> { } } +/// [`find`] for a camera written as one string, Exif make and model joined by a space (the catalog keeps +/// `"SONY ILCE-7M3"`, `"NIKON CORPORATION NIKON D850"`): every split into make and model is tried, then the +/// whole string as a model alone. Returns the same row as `find(Some(make), model)` for the parts it was +/// made of, so the catalog and the decoder agree on which cameras have matrices. +pub fn find_camera(camera: &str) -> Option<&'static Camera> { + let words: Vec<&str> = camera.split_whitespace().collect(); + if words.is_empty() { + return None; + } + (1..words.len()).find_map(|i| find(Some(&words[..i].join(" ")), &words[i..].join(" "))).or_else(|| find(None, camera)) +} + +/// The as-shot white of a file through `camera`'s matrices instead of the file's own colour tags: the DNG +/// reading of its `AsShotNeutral` or vendor white-balance multipliers (`None` when it has neither, so the +/// white is unknown). What the catalog shows as the As Shot temperature of a raw whose colour model is a +/// JPEG fit: the matrices only convert between multipliers and Kelvin here. +pub fn as_shot_white_through(camera: &Camera, color: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Option { + if color.as_shot_white_xy.is_none() + && color.as_shot_neutral.is_none() + && wb_multipliers.is_none_or(|m| m.iter().any(|v| !(*v > 0.0 && v.is_finite()))) + { + return None; + } + let mut c = ColorData { profile: Default::default(), ..color.clone() }; + camera.fill(&mut c); + Some(crate::color::as_shot_white_of(&c, wb_multipliers)) +} + /// Every camera in the table. pub fn cameras() -> &'static [Camera] { table::CAMERAS @@ -135,6 +163,60 @@ mod tests { assert_eq!(find(Some(""), ""), None); } + #[test] + fn a_make_model_string_finds_what_its_parts_find() { + // every row and alias, joined the way the catalog keeps a camera ("Make Model") + for c in cameras() { + let joined = format!("{} {}", c.make, c.model); + assert_eq!(find_camera(&joined), Some(c), "{joined}"); + assert_eq!(find_camera(&joined), find(Some(c.make), c.model), "{joined}"); + } + for (make, alias, _) in table::ALIASES { + let joined = format!("{make} {alias}"); + assert_eq!(find_camera(&joined), find(Some(make), alias), "{joined}"); + assert!(find_camera(&joined).is_some(), "{joined}"); + } + // Exif makes with more words than the brand, and a model that repeats the brand + for (make, model, row) in [ + ("NIKON CORPORATION", "NIKON D850", "D850"), + ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), + ("SONY", "ILCE-7M3", "ILCE-7M3"), + ("Canon", "Canon EOS 5D Mark III", "EOS 5D Mark III"), + ("Canon", "Canon EOS 400D DIGITAL", "Digital Rebel XTi"), + ("FUJIFILM", "GFX100S", "GFX 100"), + ("Panasonic", "DC-GX9", "DC-GX9"), + ] { + let joined = format!("{make} {model}"); + assert_eq!(find_camera(&joined).map(|c| c.model), Some(row), "{joined}"); + assert_eq!(find_camera(&joined), find(Some(make), model), "{joined}"); + } + // a model alone, as a Lightroom catalog names it + assert_eq!(find_camera("ILCE-7M3").map(|c| c.model), Some("ILCE-7M3")); + assert_eq!(find_camera(" ILCE-7M3 ").map(|c| c.model), Some("ILCE-7M3")); + for none in ["", " ", "SONY", "SONY ILCE-7M2", "Canon EOS 7D", "D850 SONY", "OLYMPUS IMAGING CORP. E-M5"] { + assert_eq!(find_camera(none), None, "{none:?}"); + } + } + + #[test] + fn as_shot_white_through_the_matrices_reads_the_multipliers() { + let c = find(Some("SONY"), "ILCE-7M3").unwrap(); + let color = ColorData::default(); + // daylight-ish Sony multipliers: a white near the locus around 5000 K, not the generic model's wild tint + let xy = as_shot_white_through(c, &color, Some([2.2, 1.0, 1.6])).unwrap(); + let (t, tint) = lightcraft_color::cct::xy_to_temp_tint(xy); + assert!((3500.0..7000.0).contains(&t) && tint.abs() < 30.0, "{t} {tint}"); + // the file's own tags are left alone + assert_eq!(color, ColorData::default()); + // unknown white: no multipliers, or unusable ones + assert_eq!(as_shot_white_through(c, &color, None), None); + assert_eq!(as_shot_white_through(c, &color, Some([0.0, 1.0, 1.0])), None); + assert_eq!(as_shot_white_through(c, &color, Some([f32::NAN, 1.0, 1.0])), None); + // a neutral in the tags counts as known + let tagged = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() }; + assert!(as_shot_white_through(c, &tagged, None).is_some()); + } + #[test] fn every_row_is_a_usable_dng_profile() { assert_eq!(cameras().len(), 52); diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 56f405ea3..ac0f4e4f9 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -82,6 +82,15 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let wb = &mut std::sync::Arc::make_mut(&mut d).wb; wb.temp = 6500.0; wb.tint = 0.0; + } else if raw { + // the Kelvin sliders show the white balance in effect: As Shot reads the file's as-shot white (the stored + // numbers can predate a decoder update, or the camera's matrices giving the file its Kelvin scale), a + // preset its Kelvin value, and a value written on the old relative scale the Kelvin it renders as + let info = app.session.source_info(id); + let (temp, tint) = lightcraft_engine::pipeline::local::effective_wb(&info, &d); + let wb = &mut std::sync::Arc::make_mut(&mut d).wb; + wb.temp = temp; + wb.tint = tint; } let preview_only = app.session.catalog.photo(id).and_then(|p| p.preview_only.clone()); diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index e933c64f7..a6a6074bf 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -24,7 +24,11 @@ One third of the pixels are held out from both fits. The combined model must red Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render jobs used header-only facts (including a hard-coded RAW WB). Decoded pixels and source facts now travel and remain cached together, including across web-worker renders. Smart previews keep the tone curve in their header (the matrix is already in their pixels), so an offline original renders and exports with the same look; proxies built before this fall back to the generic curve until rebuilt. Pixel-statistics/colour-sampling commands use decoded facts too. Render cache version 6 invalidates old thumbnails and loupe renders. -Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit. Measured/absolute-Kelvin WB remains future camera-calibration work. +Vendor RGB WB multipliers alone do **not** identify an absolute illuminant without camera calibration. The generic model inferred approximately 6829 K / −127 tint for one Sony sample; changing only temperature retained that spurious tint and caused a pink cast. Uncalibrated ARWs, NEFs, RW2s and RAFs keep the file's vendor WB at decode and expose **relative** WB controls around the as-shot look. 6500/0 is the internal neutral reference of that relative scale, not a claim about the capture's measured Kelvin temperature. Presets, picker/auto and manual controls use this reference consistently. Switching from As Shot to Custom first resolves both controls; changing one cannot inherit stale catalog tint. Reset restores the current as-shot reference. Old As Shot numbers in a catalog do not mark an otherwise unchanged photo as edited; Custom WB remains an edit. + +**Kelvin through the camera's measured matrices (issue #730).** A raw of a camera in `lightcraft_raw::spectral` (52 bodies: ILCE-7M3, ILCE-7M4, ILCE-7RM3, D850, EOS 5D Mark III…) edits white balance in Kelvin like a DNG, whichever look it got: the matrices read its as-shot multipliers as a temperature and tint (`spectral::as_shot_white_through`, the DNG `AsShotNeutral` reading with the model's `ColorMatrix1/2`) and the Temp slider runs 2000–50000 K with As Shot at that reading. The matrices do nothing else: the colour look stays the file's JPEG fit (or the profile, or the spectral fallback), and the picture is adapted from the as-shot white to the chosen one with Bradford, as every fitted look is — not re-developed in camera space as a DNG is. That keeps one adaptation model per camera whether or not a photo's fit was accepted, and it is what makes the upgrade exact: white balance written on the relative scale before this (no `scale` in the settings' `wb`, `WbScale::Legacy`) renders as the composition of the two Bradford adaptations, which is again one Bradford adaptation (`lightcraft_pipeline::local::legacy_white`), so existing edits are pixel-identical; they are converted to Custom Kelvin values the first time they are edited (`normalize_wb`). The decision is one function on both sides — `lightcraft_catalog::relative_wb_camera(format, camera)` from the catalog's camera string and `files::WbScaleOf` from the file — and a test keeps them in step. A photo imported before this keeps the relative scale's 6500 / 0 as its recorded as-shot white until its file is next decoded for display or reloaded (`Op::SetAsShotWb`, bookkeeping); only the readout waits, not the look. Raws of cameras without measured matrices stay relative. + +What the matrices say is not verified against Lightroom's Kelvin. On the ILCE-7M3 a Color Temp. series reads 7–10 % below Sony's dial through them, smoothly and monotonically, with tint near the locus (issue #730); whether the matrices or the dial are off is open (a grey card under an incandescent bulb would tell, since the Illuminant A matrix is exact there by construction). Public samples: ILCE-7M3 AWB 4268 K / +7, ILCE-7M4 AWB 4344 K / +26, GFX100S on its Daylight preset 4659 K / +9 (Fujifilm's nominal daylight is about 5300 K: the same direction and size of gap). Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust. @@ -121,9 +125,10 @@ A raw starts from the first of these that is available (`camera_preview::startin The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a profile) renders exactly as before, and a covered model takes its spectral matrices only when that fit fails the -gates or the file has no usable JPEG. White balance stays relative to the as-shot look for every raw without matrices -of its own, as before, so the catalog's `Photo::relative_wb` and the controls are unchanged. `lightcraft-cli -calibrate` still pools the uncorrected camera colour of covered models, so a profile can be fitted for them. +gates or the file has no usable JPEG. White balance of a covered model is in Kelvin through its matrices whichever +look it got (above, issue #730); for every other raw without matrices of its own it stays relative to the as-shot +look (`Photo::relative_wb`). `lightcraft-cli calibrate` still pools the uncorrected camera colour of covered +models, so a profile can be fitted for them. No colour-difference measurement of the spectral matrices against the camera JPEG, or against the file-local fit, is recorded here yet. Whether they should come before the file-local fit for these models is open until one is. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 552f76952..36030f574 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,6 +51,14 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `app.quit` | — | Close the app | +White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on +raws with colour matrices — in the file (DNG) or measured for the camera model (`lightcraft_raw::spectral`; issue +#730) — where As Shot reads the file's as-shot white; rendered files and raws without matrices use a relative scale +whose 6500 K / 0 is the as-shot look (the Edit panel shows it as −100..+100). The stored `wb` of such a Kelvin raw +says `"scale": "kelvin"`; a partial settings JSON given to `develop.merge` or a preset whose `wb` sets `temp` / `tint` +**without** `scale` keeps the relative meaning it always had (6500 = as shot) on every raw, so agents that mean +Kelvin on a Kelvin raw pass `"scale": "kelvin"` or use `develop.set` / `develop.wb`. + Image navigation is also available directly as the UI command `view.navigate`, with `{zoom?: "fit" | "fill" | {"percent": number}, pan?: [x, y]}`. Percentage zoom accepts fractional values greater than 0 and at most 800; pan is the normalized image centre, with coordinates from 0 to 1. diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 0c18a6521..6ccdc2f5a 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -47,7 +47,11 @@ A Custom white balance on a raw LightCraft develops relative to its as-shot look RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvin: the catalog keeps no as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported (issue #510; a DNG keeps the Kelvin value, and an XMP packet with `crs:AsShotTemperature` is -shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)). +shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)). A raw whose camera has +measured colour matrices (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…) edits in Kelvin +and keeps the Kelvin value too (issue #730) — when its file was found and probed, since the camera is +read from the file, not from the Lightroom catalog; a missing file is treated as a relative-scale raw +until Reload reads it. Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings; it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone is not evaluated by the importer; LightCraft's Auto control remains available after migration. diff --git a/docs/mcp.md b/docs/mcp.md index 81cbc4bd8..b12dc11bf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -164,7 +164,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re | `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were | | `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | | `get_develop {id?}` | Full develop-settings JSON, with `process`: the rendering process the photo is on ([process-versions.md](process-versions.md)) | -| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable | +| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. White balance: `values` with `wb.temp` / `wb.tint` is Kelvin on raws with colour matrices (DNG, and cameras LightCraft has matrices for) and relative (6500 = as shot) elsewhere; a `settings.wb` with `temp` / `tint` and no `"scale": "kelvin"` is read on the relative scale on every raw (see [control-protocol.md](control-protocol.md)) | | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | | `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true` | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..65c3666c7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) | +| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 393 | 39 | 84 | 36 | 192/200 (96%) | 142/152 (93%) | +| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) | +| **Total** | 397 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.0% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.2% of 163. ## Top gaps @@ -255,7 +255,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | | | LR-EDIT-COLOR-WB-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | | | LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking | -| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`), `crates/raw/src/color.rs` (`rebalance`) | relative scale for non-raw in the UI; raws with a colour matrix (DNG and other raws without a file-local look) are re-developed for the chosen white through the camera's own matrices (camera-space white balance, as Lightroom), and the eyedropper / Auto invert the same model; other photos are adapted (Bradford) | +| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open | | LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests | | LR-EDIT-COLOR-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | | | LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..a08b64b03 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,19 @@ ## October 2026 +### Kelvin white balance for raws whose camera has colour matrices +- Raws from the 52 camera models whose colour matrices LightCraft has measured (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3, + Nikon D850, Canon EOS 5D Mark III and others) now show white balance in Kelvin, as a DNG does and as Lightroom + does: As Shot reads the file's white (an ILCE-7M3 sample: 4268 K / +7), the Temp slider runs 2000–50000 K and the + Daylight, Cloudy, Shade, Tungsten, Fluorescent and Flash presets set real temperatures (issue #730). The matrices + only convert between the camera's multipliers and Kelvin; the colour look is what it was. +- Nothing already edited changes: a Custom white balance set on the old relative scale renders pixel for pixel as + before, and becomes the Kelvin value with the same look the next time it is edited. A photo imported earlier shows + its real As Shot temperature once the file is opened (or Reload reads it). +- Lightroom's `crs:Temperature` for these raws is read as the Kelvin it is (sidecars, Lightroom catalogs, presets); + raws of other cameras keep the as-shot shift of issue #510. The two Kelvin scales come from different matrices and + differ by about 7–10 % on the ILCE-7M3; which one is off is still open. + ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan, diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 59ce22b3f..b6088e88a 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -109,7 +109,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process, | `Exposure2012` | `light.exposure` | EV, 1:1 | | `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 | | `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom | -| `Temperature`, `Tint` | `wb.temp`, `wb.tint` | Kelvin / tint as written for raws with a measured illuminant (DNG) and for presets. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510) | +| `Temperature`, `Tint` | `wb.temp`, `wb.tint` (+ `wb.scale: kelvin`) | Kelvin / tint as written for raws with colour matrices — in the file (DNG) or measured for the camera (`lightcraft_raw::spectral`: ILCE-7M3, D850, EOS 5D Mark III…, issue #730) — and for presets; the mapped settings say `"scale": "kelvin"` so the value stays Kelvin wherever it lands. Raws LightCraft develops relative to their as-shot look (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW of other cameras: 6500 K / 0 means as shot) take the same mired shift from the packet's `AsShotTemperature` / `AsShotTint` instead; without that reference a custom Kelvin stays As Shot and is reported (`Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)`), because read on the relative scale it would be a large colour cast (issue #510). Note that LightCraft's Kelvin for a camera comes from its measured matrices, Lightroom's from Adobe's: the two scales differ (about 7–10 % on the ILCE-7M3), so the number is carried over, not the exact look | | `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider); a rendered file with only `Temperature` shifts from `AsShotTemperature` like the raws above when it is present | | `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 | | `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 |