# Conflicts:
#	crates/engine/src/cmd/develop.rs
#	crates/engine/src/files.rs
#	docs/mcp.md
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Claude 2026-10-11 12:02:46 +00:00
commit a0e422ce4a
No known key found for this signature in database
37 changed files with 1009 additions and 93 deletions

1
Cargo.lock generated
View File

@ -3096,6 +3096,7 @@ dependencies = [
"lightcraft-develop",
"lightcraft-geom",
"lightcraft-meta",
"lightcraft-raw",
"log",
"proptest",
"serde",

View File

@ -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<EmbeddedLens>,
@ -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()
}
}
@ -456,6 +460,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,

View File

@ -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 }

View File

@ -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)]

View File

@ -214,6 +214,13 @@ pub enum Op {
id: PhotoId,
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
},
/// 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()));

View File

@ -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);

View File

@ -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());
}
}
}

View File

@ -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]

View File

@ -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");
}

View File

@ -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 {

View File

@ -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<DevelopSettings, serde_json::Error> {
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();

View File

@ -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) {

View File

@ -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 }
}
}

View File

@ -1843,7 +1843,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}");
}
}
@ -2272,15 +2296,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}");
}
}
}

View File

@ -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<Op> {
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<Value> {
@ -248,14 +256,16 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
// 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<Op> = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).collect();
let mut mine: Vec<Op> =
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<Op> = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect();
let theirs: Vec<Op> =
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);

View File

@ -181,9 +181,12 @@ pub fn specs() -> Vec<CommandSpec> {
let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
let before = d.crop.geometry;
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);
@ -226,6 +229,7 @@ pub fn specs() -> Vec<CommandSpec> {
edit(s, "develop.adjust", spec.label, |d| {
let before = d.crop.geometry;
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;
@ -262,10 +266,17 @@ pub fn specs() -> Vec<CommandSpec> {
// of its current as-shot reference
let info = s.source_info(id);
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
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()
},
..defaults
}
};
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)
@ -402,7 +413,7 @@ pub fn specs() -> Vec<CommandSpec> {
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)),
@ -412,6 +423,7 @@ pub fn specs() -> Vec<CommandSpec> {
// 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);
@ -441,6 +453,7 @@ pub fn specs() -> Vec<CommandSpec> {
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;
@ -873,10 +886,14 @@ pub fn specs() -> Vec<CommandSpec> {
.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 before = nd.crop.geometry;
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);

View File

@ -177,7 +177,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<crate::media::SourceRef>, bool);
@ -333,7 +333,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
}
let path = crate::smart::sharded_file_path(&dir, &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 {
@ -375,7 +375,7 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
return None;
}
let max_edge = crate::media::SourceLevel::Preview.max_edge();
let photos: Vec<_> = 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<SmartJob> =
photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
std::thread::Builder::new()

View File

@ -19,10 +19,10 @@ use serde_json::{Map, Value, json};
pub type Props = BTreeMap<String, Vec<String>>;
/// 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)
}
}
@ -276,6 +277,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
@ -676,6 +682,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
@ -707,14 +717,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#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:WhiteBalance="Custom" crs:Temperature="4650" crs:Tint="+3" crs:AsShotTemperature="5400" crs:AsShotTint="-1"/>"#;
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]

View File

@ -165,10 +165,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
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 = crate::lens_profiles::for_raw(&raw, || m.clone());
return Ok(ProbeInfo {
embedded_lens,
@ -401,8 +399,9 @@ fn load_bytes_now(
let info = raw.info();
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
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);
@ -469,17 +468,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,
@ -488,7 +487,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,
@ -503,6 +503,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<lightcraft_color::Xy>,
}
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<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
@ -723,6 +764,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.

View File

@ -921,7 +921,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) => {

View File

@ -506,8 +506,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)?;

View File

@ -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};
@ -1013,8 +1013,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()
}
@ -1327,15 +1337,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<Rendered, String> {
let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
@ -1395,6 +1428,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)
}
@ -1429,7 +1465,6 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
#[cfg(test)]
mod thumbnail_hash_tests {
use super::*;
use lightcraft_catalog::Op;
fn session() -> crate::Session {
let mut s = crate::Session::new();
@ -1594,6 +1629,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));

View File

@ -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)
}

View File

@ -79,6 +79,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<Arc<CameraColor>>,
@ -98,6 +102,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,

View File

@ -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<lightcraft_color::Xy> {
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);

View File

@ -509,6 +509,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<crate::CameraColor> {
use lightcraft_color::Mat3;

View File

@ -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;

View File

@ -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<Xy> {
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);

View File

@ -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());

View File

@ -26,7 +26,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.
@ -125,9 +129,10 @@ When the file's JPEG is black and white, step 2 fits the tone curve alone with t
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.

View File

@ -79,6 +79,14 @@ filmstrip is visible.
| `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.

View File

@ -51,7 +51,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.

View File

@ -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. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted) |
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted). 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`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does |
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |

View File

@ -189,7 +189,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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`, `crates/ui-egui/src/tests_wb_selector.rs` | the Edit panel's selector button does what W does (from Compare, Survey or the grid it opens the photo in Detail); 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` | |

View File

@ -188,7 +188,7 @@ sample files. A pointer for contributors, not legal advice.
| Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) |
| Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) |
| DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) |
| White balance in Kelvin | ✅ for every model | absolute Kelvin only where matrices exist; per-file fits give relative WB (#296, #730) |
| White balance in Kelvin | ✅ for every model | absolute Kelvin where the camera has colour matrices (its own, or one of the 52 spectral rows), also for files whose look is a per-file fit (#730); other raws keep the relative scale (#296) |
**How far the default rendering is from Adobe's** (CIE ΔE; measured where stated):
@ -203,7 +203,7 @@ sample files. A pointer for contributors, not legal advice.
desaturated), the largest group of open core-path issues.
**Colour score ~35%** (estimated): most decoded files start close to the camera's own look (ΔE ~5),
about 10% start far off (ΔE ~18), nothing is calibrated, Kelvin WB is missing for fitted cameras, and
about 10% start far off (ΔE ~18), nothing is calibrated, Kelvin WB is missing for fitted cameras without colour matrices, and
the distance to Adobe Standard is unmeasured. In the readiness numbers this becomes a colour factor
of ×0.85 for a working photographer (they correct colour by hand, at a cost) and ×0.75 for the
essentials user (the default look is what they keep).
@ -257,7 +257,7 @@ Beyond decoding:
| Gap | Hours | Needs |
|---|---:|---|
| Measured colour calibration for the ~60 popular bodies, Kelvin WB, render-fidelity suite against Lightroom | 80–150 | chart shots per camera (a human with cameras) or licensable measured data |
| Measured colour calibration for the ~60 popular bodies, Kelvin WB for cameras without matrices, render-fidelity suite against Lightroom | 80–150 | chart shots per camera (a human with cameras) or licensable measured data |
| Lens profile database of our own (fitting tools, data format, top 100 lenses), maker-note vignetting / CA | 40–80 | calibration shots (a human with lenses) |
| Demosaic and highlight quality measured against Lightroom; X-Trans AI denoise | 15–30 | |
| **Total RAW** | **255–480 h** (decoding list above 120–220 h; ~60% parallelizes) | |

View File

@ -41,6 +41,19 @@
- Keywords has a + in its header, like Albums: Create Keyword Tag without opening the Keyword List.
- Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`.
### 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,

View File

@ -110,7 +110,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process,
| `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 |
| `HDREditMode` | `hdr.enabled` | `1` / `True` = HDR editing on, `0` / `False` off |
| `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 |