mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
LR-EDIT-COLOR-TEMP: Kelvin white balance for raws whose camera has colour matrices (#730)
Raws without matrices in the file (ARW, NEF, RW2, RAF, CR2, CR3, PEF, SRW,
ORF) were all edited on the relative white-balance scale (6500 K / 0 = as
shot), even when lightcraft_raw::spectral has measured matrices for the
camera. Those 52 bodies (ILCE-7M3, ILCE-7M4, D850, EOS 5D Mark III...) now
edit in Kelvin like a DNG and like Lightroom.
Design:
- One decision on both sides: spectral::find_camera("Make Model") backs
catalog::relative_wb_camera (Photo::relative_wb); files::WbScaleOf decides
the same from the decoded file. A corpus test asserts they agree.
Photo::fitted_look keeps the old predicate for smart previews.
- The matrices only convert: As Shot is the file's multipliers read through
the model's ColorMatrix1/2 (spectral::as_shot_white_through). The look
(JPEG fit / profile / spectral fallback) is unchanged, and the slider
adapts from that white with Bradford. The camera-space rebalance stays
reserved for files with their own matrices.
- Presets resolve to their Kelvin values (WbMode::preset, sources cited).
- The Color panel shows the white balance in effect (effective_wb).
- XMP: crs::Target::for_file takes the camera; matrix cameras read
crs:Temperature as Kelvin (marked "scale": "kelvin"), others keep the
#510 as-shot shift.
Migration (no look changes):
- WhiteBalance.scale: WbScale { Legacy (absent in JSON), Kelvin }. A
white balance without a scale keeps its old meaning. On a spectral-Kelvin
raw (SourceInfo.legacy_relative_wb) the pipeline composes the old
adaptation (6500 -> value) with the new one (as shot -> ...) in Bradford
cone space (local::legacy_white). Von Kries scalings compose exactly, so
the matrix matches to f32 and the pixels are identical
(pipeline legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw,
media old_relative_white_balance_on_a_kelvin_camera_keeps_its_look...).
- Every white-balance writer (develop.set/adjust/quickAdjust/wb/wbPick/
reset) first runs local::normalize_wb: a legacy value becomes the Custom
Kelvin value with the same look and is marked Kelvin. Partials without a
scale (old presets, develop.merge) stay legacy.
- Catalog format 7: Op::SetAsShotWb replaces the 6500/0 as-shot recorded
by older imports when the file is next decoded or reloaded (system op,
not an undo step). Format-version test updated.
- The web wire carries legacy_relative_wb.
Docs: parity (LR-EDIT-COLOR-TEMP), camera-preview-colour, xmp-interop,
lightroom-catalog-import, control-protocol, mcp, whats-new.
This commit is contained in:
Generated
+1
@@ -3000,6 +3000,7 @@ dependencies = [
|
|||||||
"lightcraft-develop",
|
"lightcraft-develop",
|
||||||
"lightcraft-geom",
|
"lightcraft-geom",
|
||||||
"lightcraft-meta",
|
"lightcraft-meta",
|
||||||
|
"lightcraft-raw",
|
||||||
"log",
|
"log",
|
||||||
"proptest",
|
"proptest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ pub struct WireJob {
|
|||||||
pub raw: bool,
|
pub raw: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub relative_wb: bool,
|
pub relative_wb: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub legacy_relative_wb: bool,
|
||||||
pub as_shot_temp: f64,
|
pub as_shot_temp: f64,
|
||||||
pub as_shot_tint: f64,
|
pub as_shot_tint: f64,
|
||||||
pub lens: Option<EmbeddedLens>,
|
pub lens: Option<EmbeddedLens>,
|
||||||
@@ -74,6 +76,7 @@ impl WireJob {
|
|||||||
max_edge,
|
max_edge,
|
||||||
raw: job.info.raw,
|
raw: job.info.raw,
|
||||||
relative_wb: job.info.relative_wb,
|
relative_wb: job.info.relative_wb,
|
||||||
|
legacy_relative_wb: job.info.legacy_relative_wb,
|
||||||
as_shot_temp: job.info.as_shot_temp,
|
as_shot_temp: job.info.as_shot_temp,
|
||||||
as_shot_tint: job.info.as_shot_tint,
|
as_shot_tint: job.info.as_shot_tint,
|
||||||
lens: job.info.lens,
|
lens: job.info.lens,
|
||||||
@@ -97,6 +100,7 @@ impl WireJob {
|
|||||||
as_shot_tint: self.as_shot_tint,
|
as_shot_tint: self.as_shot_tint,
|
||||||
lens: self.lens,
|
lens: self.lens,
|
||||||
relative_wb: self.relative_wb,
|
relative_wb: self.relative_wb,
|
||||||
|
legacy_relative_wb: self.legacy_relative_wb,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,6 +425,7 @@ mod tests {
|
|||||||
max_edge: 512,
|
max_edge: 512,
|
||||||
raw: false,
|
raw: false,
|
||||||
relative_wb: false,
|
relative_wb: false,
|
||||||
|
legacy_relative_wb: false,
|
||||||
as_shot_temp: 6500.0,
|
as_shot_temp: 6500.0,
|
||||||
as_shot_tint: 0.0,
|
as_shot_tint: 0.0,
|
||||||
lens: None,
|
lens: None,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
lightcraft-develop = { workspace = true }
|
lightcraft-develop = { workspace = true }
|
||||||
lightcraft-meta = { workspace = true }
|
lightcraft-meta = { workspace = true }
|
||||||
|
lightcraft-raw = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
|
//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` |
|
||||||
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
|
//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` |
|
||||||
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
|
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
|
||||||
|
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
|
||||||
//!
|
//!
|
||||||
//! Rules:
|
//! Rules:
|
||||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
//! - **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";
|
const FORMAT: &str = "lightcraft-catalog";
|
||||||
/// The catalog format this build writes (and the newest it reads). See the module docs →
|
/// 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.
|
/// *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.
|
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
|||||||
@@ -214,6 +214,13 @@ pub enum Op {
|
|||||||
id: PhotoId,
|
id: PhotoId,
|
||||||
lens: Option<Box<lightcraft_develop::EmbeddedLens>>,
|
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).
|
/// The name shown for a colour label (`None` = its colour's name).
|
||||||
SetLabelName {
|
SetLabelName {
|
||||||
label: ColorLabel,
|
label: ColorLabel,
|
||||||
@@ -685,6 +692,13 @@ impl Catalog {
|
|||||||
let p = self.photo_mut(id)?;
|
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::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 } => {
|
Op::SetFile { id, file_name, source } => {
|
||||||
if file_name.trim().is_empty() {
|
if file_name.trim().is_empty() {
|
||||||
return Err(CatalogError::Invalid("empty file name".into()));
|
return Err(CatalogError::Invalid("empty file name".into()));
|
||||||
|
|||||||
@@ -284,7 +284,8 @@ impl Op {
|
|||||||
| Op::SetFile { id, .. }
|
| Op::SetFile { id, .. }
|
||||||
| Op::Relink { id, .. }
|
| Op::Relink { id, .. }
|
||||||
| Op::SetContent { id, .. }
|
| Op::SetContent { id, .. }
|
||||||
| Op::SetEmbeddedLens { id, .. } => out(*id),
|
| Op::SetEmbeddedLens { id, .. }
|
||||||
|
| Op::SetAsShotWb { id, .. } => out(*id),
|
||||||
Op::AddAlbum { album } => {
|
Op::AddAlbum { album } => {
|
||||||
album.photos.iter().for_each(|p| out(*p));
|
album.photos.iter().for_each(|p| out(*p));
|
||||||
album.cover.into_iter().for_each(&mut *out);
|
album.cover.into_iter().for_each(&mut *out);
|
||||||
|
|||||||
+71
-11
@@ -307,14 +307,25 @@ pub struct Analysis {
|
|||||||
pub best: bool,
|
pub best: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Raw formats whose readers have vendor white-balance multipliers but no measured camera
|
/// Raw formats whose files carry vendor white-balance multipliers but no colour matrices: their starting
|
||||||
/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means
|
/// look is fitted to the file's own JPEG (the engine's `camera_preview::file_local_look` lists the same
|
||||||
/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's
|
/// formats). `format` is the file's extension or Lightroom's `fileFormat` name, in any case. Whether such a
|
||||||
/// `fileFormat` name, in any case. See [`Photo::relative_wb`].
|
/// 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 {
|
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))
|
["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 {
|
impl Photo {
|
||||||
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
|
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
|
||||||
Photo {
|
Photo {
|
||||||
@@ -355,13 +366,18 @@ impl Photo {
|
|||||||
pub fn develops_raw(&self) -> bool {
|
pub fn develops_raw(&self) -> bool {
|
||||||
self.kind == MediaKind::Raw && self.preview_only.is_none()
|
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
|
/// A raw developed from a look fitted to its own JPEG (ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF: vendor
|
||||||
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
|
/// WB multipliers, no colour matrices in the file; RWL and RAW are Leica's and the oldest Panasonic bodies'
|
||||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
/// names for RW2 files). The engine's `camera_preview::file_local_look` covers the same formats.
|
||||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
pub fn fitted_look(&self) -> bool {
|
||||||
pub fn relative_wb(&self) -> bool {
|
|
||||||
self.develops_raw() && relative_wb_format(&self.format)
|
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
|
/// 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,
|
/// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults,
|
||||||
/// in the photo's own rendering process.
|
/// 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?
|
/// 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.
|
/// still as imported?) don't change when a newer process exists. A new photo has the latest.
|
||||||
pub fn camera_defaults(&self) -> DevelopSettings {
|
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 {
|
let mut d = match wb {
|
||||||
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
|
Some((t, tint)) => DevelopSettings::for_raw(t, tint),
|
||||||
None => DevelopSettings::default(),
|
None => DevelopSettings::default(),
|
||||||
};
|
};
|
||||||
|
if self.develops_raw() && !relative {
|
||||||
|
d.wb.scale = lightcraft_develop::WbScale::Kelvin;
|
||||||
|
}
|
||||||
if self.embedded_lens.is_some() {
|
if self.embedded_lens.is_some() {
|
||||||
d.optics.lens_profile = true;
|
d.optics.lens_profile = true;
|
||||||
}
|
}
|
||||||
@@ -487,6 +507,7 @@ impl Album {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod edited_tests {
|
mod edited_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use lightcraft_develop::WbScale;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn raw_with_import_settings_is_not_edited() {
|
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");
|
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.kind = MediaKind::Raw;
|
||||||
|
p.meta.camera = "Pentax K-1".into();
|
||||||
p.as_shot_wb = Some((5200.0, 4.0));
|
p.as_shot_wb = Some((5200.0, 4.0));
|
||||||
assert_eq!(p.relative_wb(), relative, "{format}");
|
assert_eq!(p.relative_wb(), relative, "{format}");
|
||||||
|
assert_eq!(p.fitted_look(), relative, "{format}");
|
||||||
let wb = p.camera_defaults().wb;
|
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.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));
|
p.develop = Arc::new(DevelopSettings::for_raw(5200.0, 4.0));
|
||||||
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
|
assert!(!p.is_edited(), "{format}: old As Shot values are not an edit");
|
||||||
let mut custom = (*p.develop).clone();
|
let mut custom = (*p.develop).clone();
|
||||||
@@ -559,7 +583,43 @@ mod edited_tests {
|
|||||||
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
|
assert!(p.is_edited(), "{format}: Custom WB is still an edit");
|
||||||
// shown from the embedded preview: a rendered image, not a relative-WB raw
|
// shown from the embedded preview: a rendered image, not a relative-WB raw
|
||||||
p.preview_only = Some("unsupported".into());
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,6 +476,27 @@ fn set_embedded_lens_is_undoable_and_journaled() {
|
|||||||
assert_eq!(c.photo(a).unwrap().embedded_lens, None);
|
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,
|
/// 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.
|
/// with its problems; a good one has none; an old album operator is read as it was meant.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -210,8 +210,9 @@ fn op_variants_are_versioned() {
|
|||||||
Op::SetEmbeddedLens { .. } => 4,
|
Op::SetEmbeddedLens { .. } => 4,
|
||||||
Op::SetKeyword { .. } => 5,
|
Op::SetKeyword { .. } => 5,
|
||||||
Op::SetFolderRecord { .. } => 6,
|
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");
|
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
/// Bradford chromatic adaptation matrix (XYZ → XYZ) from white `src` to white `dst`.
|
||||||
pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
|
pub fn bradford(src: Xy, dst: Xy) -> Mat3 {
|
||||||
|
|||||||
@@ -38,11 +38,21 @@ impl DevelopSettings {
|
|||||||
serde_json::from_value(v.clone())
|
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> {
|
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
|
||||||
let mut v = self.to_json();
|
let mut v = self.to_json();
|
||||||
presets::deep_merge(&mut v, partial);
|
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.
|
/// 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());
|
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]
|
#[test]
|
||||||
fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
|
fn default_hdr_is_not_serialized_so_existing_hashes_stay() {
|
||||||
let s = DevelopSettings::default();
|
let s = DevelopSettings::default();
|
||||||
|
|||||||
@@ -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 {
|
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
|
||||||
let base = s.to_json();
|
let base = s.to_json();
|
||||||
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
|
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());
|
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
|
||||||
for c in crate::controls::CONTROLS {
|
for c in crate::controls::CONTROLS {
|
||||||
if let Some(v) = crate::controls::get(&out, c.id) {
|
if let Some(v) = crate::controls::get(&out, c.id) {
|
||||||
|
|||||||
@@ -242,7 +242,13 @@ impl WbMode {
|
|||||||
WbMode::Custom => "Custom",
|
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)> {
|
pub fn preset(self) -> Option<(f64, f64)> {
|
||||||
match self {
|
match self {
|
||||||
WbMode::Daylight => Some((5500.0, 10.0)),
|
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
|
/// Which scale a [`WhiteBalance`]'s `temp` / `tint` were written on.
|
||||||
/// (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).
|
/// 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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct WhiteBalance {
|
pub struct WhiteBalance {
|
||||||
@@ -266,11 +298,17 @@ pub struct WhiteBalance {
|
|||||||
pub temp: f64,
|
pub temp: f64,
|
||||||
/// Correction direction: negative adds green, positive adds magenta.
|
/// Correction direction: negative adds green, positive adds magenta.
|
||||||
pub tint: f64,
|
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 {
|
impl Default for WhiteBalance {
|
||||||
fn default() -> Self {
|
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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1559,7 +1559,31 @@ mod tests {
|
|||||||
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
|
assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}");
|
||||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||||
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
|
assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}");
|
||||||
assert_eq!(info.relative_wb, !own, "{name}");
|
// white balance: relative only without matrices anywhere; a covered camera edits in Kelvin, through the
|
||||||
|
// spectral matrices, whichever look it got (issue #730)
|
||||||
|
assert_eq!(info.relative_wb, !own && !covered, "{name}");
|
||||||
|
assert_eq!(info.legacy_relative_wb, !own && covered, "{name}");
|
||||||
|
if covered && !own {
|
||||||
|
assert!(
|
||||||
|
(2000.0..=50000.0).contains(&info.as_shot_temp) && info.as_shot_temp != 6500.0 && info.as_shot_tint.abs() < 100.0,
|
||||||
|
"{name}: {info:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// the same as the catalog decides from the probe (format and camera)
|
||||||
|
let probe = crate::files::probe_bytes(name, &bytes).unwrap();
|
||||||
|
let mut p = lightcraft_catalog::Photo::new(
|
||||||
|
lightcraft_catalog::PhotoId(1),
|
||||||
|
lightcraft_catalog::Source::File { path: name.into() },
|
||||||
|
name,
|
||||||
|
&probe.format,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
p.kind = probe.kind;
|
||||||
|
p.meta = probe.meta.clone();
|
||||||
|
assert_eq!(p.relative_wb(), info.relative_wb, "{name}");
|
||||||
|
assert_eq!(probe.as_shot_wb, Some((info.as_shot_temp, info.as_shot_tint)), "{name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1988,15 +2012,24 @@ mod tests {
|
|||||||
.map(std::path::PathBuf::from)
|
.map(std::path::PathBuf::from)
|
||||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||||
.join("raw");
|
.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 Ok(bytes) = std::fs::read(dir.join(name)) else { continue };
|
||||||
let header = crate::files::probe_bytes(name, &bytes).unwrap();
|
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 (_, small) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||||
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
|
let (_, large) = crate::files::load_bytes(&bytes, 1200).unwrap();
|
||||||
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
|
assert_eq!(small.camera_tone.is_some(), accepted, "{name}");
|
||||||
assert_eq!(small.camera_tone, large.camera_tone, "{name}: resolution changed the fit");
|
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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,14 @@ pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog:
|
|||||||
ops
|
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,
|
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
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;
|
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||||
// nothing already set is overwritten
|
// nothing already set is overwritten
|
||||||
let meta_ops = fill_missing_meta(id, ph, &info);
|
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() {
|
if !mine.is_empty() {
|
||||||
ops.append(&mut mine);
|
ops.append(&mut mine);
|
||||||
reloaded.push(id);
|
reloaded.push(id);
|
||||||
}
|
}
|
||||||
// virtual copies share the file
|
// virtual copies share the file
|
||||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
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() {
|
if !theirs.is_empty() {
|
||||||
ops.extend(theirs);
|
ops.extend(theirs);
|
||||||
reloaded.push(c.id);
|
reloaded.push(c.id);
|
||||||
|
|||||||
@@ -168,9 +168,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
}
|
}
|
||||||
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
||||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
||||||
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||||
d.wb.temp = info.as_shot_temp;
|
lightcraft_pipeline::local::normalize_wb(d, info);
|
||||||
d.wb.tint = info.as_shot_tint;
|
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 {
|
for (k, v) in &vals {
|
||||||
controls::set(d, k, *v);
|
controls::set(d, k, *v);
|
||||||
@@ -209,6 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
let info = s.source_info(active(s, "develop.adjust")?);
|
let info = s.source_info(active(s, "develop.adjust")?);
|
||||||
edit(s, "develop.adjust", spec.label, |d| {
|
edit(s, "develop.adjust", spec.label, |d| {
|
||||||
if c == "wb.temp" || c == "wb.tint" {
|
if c == "wb.temp" || c == "wb.tint" {
|
||||||
|
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||||
if d.wb.mode == WbMode::AsShot {
|
if d.wb.mode == WbMode::AsShot {
|
||||||
d.wb.temp = info.as_shot_temp;
|
d.wb.temp = info.as_shot_temp;
|
||||||
d.wb.tint = info.as_shot_tint;
|
d.wb.tint = info.as_shot_tint;
|
||||||
@@ -240,10 +244,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
Some(l) => (*l).clone(),
|
Some(l) => (*l).clone(),
|
||||||
None => {
|
None => {
|
||||||
let info = s.source_info(id);
|
let info = s.source_info(id);
|
||||||
DevelopSettings {
|
let mut fresh = DevelopSettings {
|
||||||
wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, temp: info.as_shot_temp, tint: info.as_shot_tint },
|
wb: lightcraft_develop::WhiteBalance {
|
||||||
|
mode: WbMode::AsShot,
|
||||||
|
temp: info.as_shot_temp,
|
||||||
|
tint: info.as_shot_tint,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
};
|
||||||
|
lightcraft_pipeline::local::normalize_wb(&mut fresh, &info);
|
||||||
|
fresh
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
||||||
@@ -377,7 +388,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
m => m
|
m => m
|
||||||
.preset()
|
.preset()
|
||||||
.map(|(t, ti)| {
|
.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) }
|
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)),
|
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
|
||||||
@@ -387,6 +398,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
|
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
|
||||||
// mode is kept for the UI's WB dropdown.
|
// mode is kept for the UI's WB dropdown.
|
||||||
edit(s, "develop.wb", "White Balance", |d| {
|
edit(s, "develop.wb", "White Balance", |d| {
|
||||||
|
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||||
d.wb.mode = mode;
|
d.wb.mode = mode;
|
||||||
d.wb.temp = t.clamp(2000.0, 50000.0);
|
d.wb.temp = t.clamp(2000.0, 50000.0);
|
||||||
d.wb.tint = tint.clamp(-150.0, 150.0);
|
d.wb.tint = tint.clamp(-150.0, 150.0);
|
||||||
@@ -416,6 +428,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
|
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
|
||||||
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
|
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
|
||||||
edit(s, "develop.wbPick", "White Balance", |d| {
|
edit(s, "develop.wbPick", "White Balance", |d| {
|
||||||
|
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||||||
d.wb.mode = WbMode::Custom;
|
d.wb.mode = WbMode::Custom;
|
||||||
d.wb.temp = t;
|
d.wb.temp = t;
|
||||||
d.wb.tint = tint;
|
d.wb.tint = tint;
|
||||||
@@ -847,10 +860,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||||
.filter_map(|(id, d)| {
|
.filter_map(|(id, d)| {
|
||||||
let mut nd = (*d).clone();
|
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);
|
let info = s.source_info(id);
|
||||||
nd.wb.temp = info.as_shot_temp;
|
lightcraft_pipeline::local::normalize_wb(&mut nd, &info);
|
||||||
nd.wb.tint = info.as_shot_tint;
|
if nd.wb.mode == WbMode::AsShot {
|
||||||
|
nd.wb.temp = info.as_shot_temp;
|
||||||
|
nd.wb.tint = info.as_shot_tint;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||||
controls::set(&mut nd, &ctl, v + delta);
|
controls::set(&mut nd, &ctl, v + delta);
|
||||||
|
|||||||
@@ -126,7 +126,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,
|
/// 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 —
|
/// 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).
|
/// proxies go stale when the fit changes).
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
|
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
|
||||||
@@ -272,7 +272,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
let path = dir.join(crate::smart::file_name(&ph));
|
let path = dir.join(crate::smart::file_name(&ph));
|
||||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
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();
|
let custom = s.smart_previews_dir.is_some();
|
||||||
if background {
|
if background {
|
||||||
@@ -314,7 +314,7 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let max_edge = crate::media::SourceLevel::Preview.max_edge();
|
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> =
|
let jobs: Vec<SmartJob> =
|
||||||
photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
|
photos.iter().map(|p| (p.id, dir.join(crate::smart::file_name(p)), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
|
|||||||
+62
-16
@@ -19,10 +19,10 @@ use serde_json::{Map, Value, json};
|
|||||||
pub type Props = BTreeMap<String, Vec<String>>;
|
pub type Props = BTreeMap<String, Vec<String>>;
|
||||||
|
|
||||||
/// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw
|
/// 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
|
/// with a Kelvin scale (colour matrices in the file, or measured for its camera) takes `Temperature`
|
||||||
/// as-shot look (rendered files, and raws whose readers have no illuminant,
|
/// as Kelvin; everything developed relative to its as-shot look (rendered files, and raws without
|
||||||
/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white
|
/// matrices anywhere, [`lightcraft_catalog::relative_wb_camera`]) needs a shift from the other app's
|
||||||
/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
|
/// as-shot white instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum Target {
|
pub enum Target {
|
||||||
/// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale.
|
/// 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
|
/// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our
|
||||||
/// relative scale.
|
/// relative scale.
|
||||||
Rendered,
|
Rendered,
|
||||||
/// A raw developed with an absolute white balance (DNG and other files with a measured
|
/// A raw developed with an absolute white balance (DNG and other files with matrices, and raws
|
||||||
/// illuminant): Kelvin as written.
|
/// whose camera has measured matrices): Kelvin as written.
|
||||||
RawAbsolute,
|
RawAbsolute,
|
||||||
/// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]).
|
/// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]).
|
||||||
RawRelative,
|
RawRelative,
|
||||||
@@ -39,11 +39,12 @@ pub enum Target {
|
|||||||
|
|
||||||
impl Target {
|
impl Target {
|
||||||
/// The target for a file of this media kind and format (its extension or Lightroom's
|
/// 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.
|
/// `fileFormat` name) shot on `camera` (Exif make and model as the catalog keeps them, or
|
||||||
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target {
|
/// 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 {
|
if kind != lightcraft_catalog::MediaKind::Raw || preview_only {
|
||||||
Target::Rendered
|
Target::Rendered
|
||||||
} else if lightcraft_catalog::relative_wb_format(format) {
|
} else if lightcraft_catalog::relative_wb_camera(format, camera) {
|
||||||
Target::RawRelative
|
Target::RawRelative
|
||||||
} else {
|
} else {
|
||||||
Target::RawAbsolute
|
Target::RawAbsolute
|
||||||
@@ -51,7 +52,7 @@ impl Target {
|
|||||||
}
|
}
|
||||||
/// The target for a photo in the catalog.
|
/// The target for a photo in the catalog.
|
||||||
pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target {
|
pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target {
|
||||||
Target::for_file(p.kind, &p.format, p.preview_only.is_some())
|
Target::for_file(p.kind, &p.format, p.preview_only.is_some(), &p.meta.camera)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +273,11 @@ pub fn to_partial(props: &Props, target: Target) -> Value {
|
|||||||
if let Some(tint) = tint {
|
if let Some(tint) = tint {
|
||||||
put(o, "wb.tint", json!(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
|
// 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
|
// large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so
|
||||||
@@ -643,6 +649,10 @@ mod tests {
|
|||||||
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]);
|
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]);
|
||||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
||||||
assert_eq!(to_partial(&p, Target::Any)["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
|
// 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());
|
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
|
// no tint next to the as-shot tint: the custom tint is taken as the shift itself
|
||||||
@@ -674,14 +684,50 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn target_follows_the_file() {
|
fn target_follows_the_file() {
|
||||||
use lightcraft_catalog::MediaKind;
|
use lightcraft_catalog::MediaKind;
|
||||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative);
|
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, "nef", false, "NIKON CORPORATION NIKON D7500"), Target::RawRelative);
|
||||||
// Lightroom's `fileFormat` name for proprietary raws
|
// 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, "RAW", false, "SONY ILCE-7M2"), Target::RawRelative);
|
||||||
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute);
|
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false, ""), Target::RawAbsolute);
|
||||||
// a raw shown from its embedded preview develops like a rendered file
|
// 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::Raw, "ARW", true, "SONY ILCE-7M3"), Target::Rendered);
|
||||||
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), 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]
|
#[test]
|
||||||
|
|||||||
+93
-13
@@ -131,10 +131,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
|||||||
if raw.orientation.swaps_axes() {
|
if raw.orientation.swaps_axes() {
|
||||||
std::mem::swap(&mut w, &mut h);
|
std::mem::swap(&mut w, &mut h);
|
||||||
}
|
}
|
||||||
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
|
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||||
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
|
let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw)));
|
||||||
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 embedded_lens = embedded_lens(&raw);
|
let embedded_lens = embedded_lens(&raw);
|
||||||
return Ok(ProbeInfo {
|
return Ok(ProbeInfo {
|
||||||
embedded_lens,
|
embedded_lens,
|
||||||
@@ -367,8 +365,9 @@ fn load_bytes_now(
|
|||||||
let info = raw.info();
|
let info = raw.info();
|
||||||
let lens = embedded_lens(&info);
|
let lens = embedded_lens(&info);
|
||||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
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`).
|
// Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or
|
||||||
let own_matrix = lightcraft_raw::color::has_matrix(&raw.color);
|
// 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
|
// 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));
|
let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info));
|
||||||
drop(bytes);
|
drop(bytes);
|
||||||
@@ -435,17 +434,17 @@ fn load_bytes_now(
|
|||||||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
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 twin = twin.map(|picture| finish(picture).0);
|
||||||
let (temp, tint) = xy_to_temp_tint(xy);
|
// White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits
|
||||||
let relative = crate::camera_preview::file_local_look(raw.format) && !own_matrix;
|
// on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the
|
||||||
// White balance re-evaluates the file's own colour model (when it has one and no
|
// picture is adapted from the as-shot white as every fitted look is (so settings from before that scale
|
||||||
// file-local look matrix sits on top of it)
|
// existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`).
|
||||||
let camera_color = (!t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
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() };
|
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||||||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
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 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 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((
|
return Ok((
|
||||||
img,
|
img,
|
||||||
twin,
|
twin,
|
||||||
@@ -454,7 +453,8 @@ fn load_bytes_now(
|
|||||||
as_shot_temp: temp,
|
as_shot_temp: temp,
|
||||||
as_shot_tint: tint,
|
as_shot_tint: tint,
|
||||||
lens,
|
lens,
|
||||||
relative_wb: relative,
|
relative_wb: scale.relative,
|
||||||
|
legacy_relative_wb: scale.legacy_relative,
|
||||||
camera_color,
|
camera_color,
|
||||||
camera_tone,
|
camera_tone,
|
||||||
mattes,
|
mattes,
|
||||||
@@ -469,6 +469,47 @@ fn load_bytes_now(
|
|||||||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
|
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 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.
|
/// 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>> {
|
fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
|
||||||
@@ -689,6 +730,45 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
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:
|
/// 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
|
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
|
||||||
/// file or one that isn't an image.
|
/// file or one that isn't an image.
|
||||||
|
|||||||
@@ -912,7 +912,7 @@ impl ImportJob {
|
|||||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
.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) {
|
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
|
||||||
Ok(sc) => Some(sc),
|
Ok(sc) => Some(sc),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@@ -471,8 +471,8 @@ impl Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
|
/// 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.
|
/// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
/// redo stack alone.
|
||||||
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
|
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
|
||||||
let fwd = op.clone();
|
let fwd = op.clone();
|
||||||
self.catalog.apply(op)?;
|
self.catalog.apply(op)?;
|
||||||
|
|||||||
+115
-5
@@ -19,7 +19,7 @@
|
|||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::{Arc, Weak};
|
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_develop::DevelopSettings;
|
||||||
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
use lightcraft_pipeline::{Quality, RenderRequest, Rendered, SourceInfo, StageCache};
|
||||||
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
|
use lightcraft_preview::{Hash128, Hasher128, Lru, PreviewCache};
|
||||||
@@ -940,8 +940,18 @@ pub fn source_info(p: &Photo) -> SourceInfo {
|
|||||||
// A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white
|
// 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.
|
// balance and the display tone curve, like any other rendered file.
|
||||||
if p.develops_raw() {
|
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)) };
|
let relative = p.relative_wb();
|
||||||
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens: p.embedded_lens, relative_wb: p.relative_wb(), ..Default::default() }
|
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 {
|
} else {
|
||||||
SourceInfo::default()
|
SourceInfo::default()
|
||||||
}
|
}
|
||||||
@@ -1254,15 +1264,38 @@ impl crate::Session {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accept a finished job's loaded source into the cache.
|
/// Accept a finished job's loaded source into the cache. What the decode learnt about the file that the catalog
|
||||||
|
/// has wrong is corrected too ([`Self::learn_as_shot_wb`]).
|
||||||
pub fn accept(&mut self, r: &RenderResult) {
|
pub fn accept(&mut self, r: &RenderResult) {
|
||||||
if let Some(src) = &r.loaded
|
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))
|
&& 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());
|
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).
|
/// Synchronous render (CLI, MCP, tests).
|
||||||
pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result<Rendered, String> {
|
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")?;
|
let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
|
||||||
@@ -1322,6 +1355,9 @@ impl crate::Session {
|
|||||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||||
let r = self.media.source_ref(&p, level).load_source()?;
|
let r = self.media.source_ref(&p, level).load_source()?;
|
||||||
let image = r.image.clone();
|
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);
|
self.media.insert_source(id, level, r);
|
||||||
Ok(image)
|
Ok(image)
|
||||||
}
|
}
|
||||||
@@ -1356,7 +1392,6 @@ pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod thumbnail_hash_tests {
|
mod thumbnail_hash_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use lightcraft_catalog::Op;
|
|
||||||
|
|
||||||
fn session() -> crate::Session {
|
fn session() -> crate::Session {
|
||||||
let mut s = crate::Session::new();
|
let mut s = crate::Session::new();
|
||||||
@@ -1504,6 +1539,81 @@ mod tests {
|
|||||||
assert!(s.media.get_source(id, r.level).is_none());
|
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]
|
#[test]
|
||||||
fn budget_evicts_least_recently_used_across_caches() {
|
fn budget_evicts_least_recently_used_across_caches() {
|
||||||
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
let img = |w: usize| Arc::new(Rgb32f::new(w, w));
|
||||||
|
|||||||
@@ -121,3 +121,102 @@ fn targeted_adjustment_on_curve_and_mixer() {
|
|||||||
assert!(o.values().all(|v| v.as_f64().unwrap() < 0.0));
|
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());
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ pub struct SourceInfo {
|
|||||||
pub as_shot_tint: f64,
|
pub as_shot_tint: f64,
|
||||||
/// No measured camera illuminant: WB adjustments are relative to the camera's rendered look.
|
/// No measured camera illuminant: WB adjustments are relative to the camera's rendered look.
|
||||||
pub relative_wb: bool,
|
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 camera's colour model (raw files with a colour matrix); `None`: white balance adapts
|
||||||
/// the developed pixels (Bradford, linear Rec.2020).
|
/// the developed pixels (Bradford, linear Rec.2020).
|
||||||
pub camera_color: Option<Arc<CameraColor>>,
|
pub camera_color: Option<Arc<CameraColor>>,
|
||||||
@@ -97,6 +101,7 @@ impl Default for SourceInfo {
|
|||||||
as_shot_tint: 0.0,
|
as_shot_tint: 0.0,
|
||||||
lens: None,
|
lens: None,
|
||||||
relative_wb: false,
|
relative_wb: false,
|
||||||
|
legacy_relative_wb: false,
|
||||||
camera_color: None,
|
camera_color: None,
|
||||||
camera_tone: None,
|
camera_tone: None,
|
||||||
mattes: None,
|
mattes: None,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Scene-linear preparation: white balance + exposure, and the spatial planes the per-pixel stage
|
//! 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).
|
//! 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_color::{REC2020, luminance_2020};
|
||||||
use lightcraft_develop::DevelopSettings;
|
use lightcraft_develop::DevelopSettings;
|
||||||
use lightcraft_raster::blur::gaussian;
|
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());
|
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 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));
|
let m = set.mul(&shot.inverse().unwrap_or(lightcraft_color::Mat3::IDENTITY));
|
||||||
// Normalize so neutral luminance is preserved (WB shouldn't change exposure).
|
// 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) {
|
pub fn effective_wb(info: &SourceInfo, s: &DevelopSettings) -> (f64, f64) {
|
||||||
use lightcraft_develop::WbMode;
|
use lightcraft_develop::WbMode;
|
||||||
match s.wb.mode {
|
match s.wb.mode {
|
||||||
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
|
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)),
|
m if info.raw && !info.relative_wb => m.preset().unwrap_or((s.wb.temp, s.wb.tint)),
|
||||||
_ => (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.
|
/// 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 {
|
pub fn guided(p: &Plane, sigma: f32, eps: f32) -> Plane {
|
||||||
let (ma, mb) = guided_coeffs(p, sigma, eps);
|
let (ma, mb) = guided_coeffs(p, sigma, eps);
|
||||||
|
|||||||
@@ -427,6 +427,114 @@ fn tint_negative_is_green_and_positive_is_magenta() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings written on the relative scale (6500 K / 0 = as shot, no `WbScale`) for a raw whose camera matrices now
|
||||||
|
/// give it a Kelvin scale render exactly as they did: the same white-balance matrix, and the same pixels, as the
|
||||||
|
/// relative render; the Kelvin / tint they convert to renders the same again (issue #730, the class of bug of #510).
|
||||||
|
#[test]
|
||||||
|
fn legacy_relative_white_balance_renders_as_before_on_a_kelvin_raw() {
|
||||||
|
use crate::local::{effective_wb, legacy_to_kelvin, wb_matrix_for};
|
||||||
|
use lightcraft_develop::{WbMode, WbScale};
|
||||||
|
let old_info = SourceInfo { raw: true, relative_wb: true, as_shot_temp: 6500.0, as_shot_tint: 0.0, ..Default::default() };
|
||||||
|
let src = Rgb32f::filled(16, 16, [0.3, 0.18, 0.09]);
|
||||||
|
// (as-shot whites the ILCE-7M3 table of issue #730 reads, and the relative slider's whole reach: −100..+100 is
|
||||||
|
// 2000..13550 K around 6500 K; a white bluer than 50000 K after the composition has no Kelvin and clamps, so
|
||||||
|
// the blue end is paired with as-shot whites up to 7000 K only)
|
||||||
|
for (as_t, as_tint) in [(4986.0, -2.4), (2707.0, -4.0), (9338.0, 8.9), (6500.0, 0.0), (3157.0, 40.0), (7000.0, -20.0)] {
|
||||||
|
let new_info =
|
||||||
|
SourceInfo { raw: true, relative_wb: false, legacy_relative_wb: true, as_shot_temp: as_t, as_shot_tint: as_tint, ..Default::default() };
|
||||||
|
let blue_end = as_t <= 7000.0;
|
||||||
|
for (temp, tint) in [(6500.0, 0.0), (5800.0, 3.0), (7682.0, 10.0), (4000.0, -30.0), (2000.0, 150.0), (12000.0, 55.0), (13550.0, -150.0)] {
|
||||||
|
if temp > 10000.0 && !blue_end {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut s = DevelopSettings::default();
|
||||||
|
(s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, temp, tint);
|
||||||
|
assert_eq!(s.wb.scale, WbScale::Legacy);
|
||||||
|
let old = wb_matrix_for(&old_info, &s);
|
||||||
|
let new = wb_matrix_for(&new_info, &s);
|
||||||
|
let near = |a: Option<[[f32; 3]; 3]>, b: Option<[[f32; 3]; 3]>, tol: f32| match (a, b) {
|
||||||
|
(None, None) => true,
|
||||||
|
(Some(a), Some(b)) => a.iter().flatten().zip(b.iter().flatten()).all(|(x, y)| (x - y).abs() <= tol * (1.0 + x.abs())),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
// the same matrix to f32 precision: the white is composed, not read back through Kelvin / tint
|
||||||
|
assert!(near(old, new, 2e-6), "as shot {as_t}/{as_tint}, {temp}/{tint}: {old:?} vs {new:?}");
|
||||||
|
// the converted Kelvin values, once written, render the same again to the precision of the Kelvin /
|
||||||
|
// tint parametrisation (whose seam at 4000 K costs up to 0.01 %)
|
||||||
|
let (kt, ktint) = legacy_to_kelvin(temp, tint, (as_t, as_tint));
|
||||||
|
assert_eq!(effective_wb(&new_info, &s), (kt, ktint));
|
||||||
|
let mut k = s.clone();
|
||||||
|
(k.wb.temp, k.wb.tint, k.wb.scale) = (kt, ktint, WbScale::Kelvin);
|
||||||
|
assert!(near(old, wb_matrix_for(&new_info, &k), 3e-4), "as shot {as_t}/{as_tint}, {temp}/{tint} → {kt}/{ktint}");
|
||||||
|
// and the rendered pixels agree
|
||||||
|
let a = render(&src, &old_info, &s, &RenderRequest::fit(16, 16)).image;
|
||||||
|
let b = render(&src, &new_info, &s, &RenderRequest::fit(16, 16)).image;
|
||||||
|
let c = render(&src, &new_info, &k, &RenderRequest::fit(16, 16)).image;
|
||||||
|
for (x, (y, z)) in a.data.iter().zip(b.data.iter().zip(&c.data)) {
|
||||||
|
assert_eq!(x, y, "as shot {as_t}/{as_tint}, {temp}/{tint}");
|
||||||
|
assert!(x.iter().zip(z).all(|(p, q)| p.abs_diff(*q) <= 1), "{x:?} {z:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// as shot on the relative scale is as shot on the Kelvin scale, exactly
|
||||||
|
assert_eq!(legacy_to_kelvin(6500.0, 0.0, (as_t, as_tint)), (as_t, as_tint));
|
||||||
|
}
|
||||||
|
// a preset stored on the relative scale keeps its old look, not the preset's Kelvin value
|
||||||
|
let new_info = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.4, ..Default::default() };
|
||||||
|
let mut s = DevelopSettings::default();
|
||||||
|
(s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Cloudy, 6500.0 * 6500.0 / 5500.0, 10.0);
|
||||||
|
assert_eq!(effective_wb(&new_info, &s), legacy_to_kelvin(s.wb.temp, s.wb.tint, (4986.0, -2.4)));
|
||||||
|
s.wb.scale = WbScale::Kelvin;
|
||||||
|
assert_eq!(effective_wb(&new_info, &s), WbMode::Cloudy.preset().unwrap());
|
||||||
|
// a raw whose file carries the matrices (DNG) never had a relative scale: its old values are Kelvin
|
||||||
|
let dng = SourceInfo { raw: true, as_shot_temp: 5100.0, as_shot_tint: 4.0, ..Default::default() };
|
||||||
|
s.wb.scale = WbScale::Legacy;
|
||||||
|
(s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0);
|
||||||
|
assert_eq!(effective_wb(&dng, &s), (3200.0, 10.0));
|
||||||
|
// hostile numbers come back unchanged
|
||||||
|
assert!(legacy_to_kelvin(f64::NAN, 0.0, (5000.0, 0.0)).0.is_nan());
|
||||||
|
assert_eq!(legacy_to_kelvin(5000.0, f64::INFINITY, (5000.0, 0.0)).1, f64::INFINITY);
|
||||||
|
assert_eq!(legacy_to_kelvin(5000.0, 0.0, (f64::NAN, 0.0)), (5000.0, 0.0));
|
||||||
|
assert_eq!(legacy_to_kelvin(6500.0, 0.0, (f64::NAN, 0.0)), (6500.0, 0.0));
|
||||||
|
assert_eq!(crate::local::legacy_white(f64::NAN, 0.0, (5000.0, 0.0)), None);
|
||||||
|
let hostile = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: f64::NAN, as_shot_tint: 0.0, ..Default::default() };
|
||||||
|
let _ = render(&Rgb32f::filled(4, 4, [0.2; 3]), &hostile, &s, &RenderRequest::fit(4, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Before an edit, the settings' white balance is brought onto the source's scale: Kelvin raws mark it (converting
|
||||||
|
/// relative values once, into a Custom value with the same look), everything else clears it.
|
||||||
|
#[test]
|
||||||
|
fn normalize_wb_marks_kelvin_raws_and_converts_relative_values_once() {
|
||||||
|
use crate::local::{legacy_to_kelvin, normalize_wb};
|
||||||
|
use lightcraft_develop::{WbMode, WbScale};
|
||||||
|
let kelvin = SourceInfo { raw: true, legacy_relative_wb: true, as_shot_temp: 4986.0, as_shot_tint: -2.4, ..Default::default() };
|
||||||
|
// as shot: marked, values untouched
|
||||||
|
let mut s = DevelopSettings::default();
|
||||||
|
normalize_wb(&mut s, &kelvin);
|
||||||
|
assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::AsShot, 6500.0, 0.0, WbScale::Kelvin));
|
||||||
|
// a relative custom value: converted, Custom, marked; a second pass changes nothing
|
||||||
|
let mut s = DevelopSettings::default();
|
||||||
|
(s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Cloudy, 7682.0, 10.0);
|
||||||
|
normalize_wb(&mut s, &kelvin);
|
||||||
|
let (t, tint) = legacy_to_kelvin(7682.0, 10.0, (4986.0, -2.4));
|
||||||
|
assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::Custom, t, tint, WbScale::Kelvin));
|
||||||
|
let again = s.clone();
|
||||||
|
normalize_wb(&mut s, &kelvin);
|
||||||
|
assert_eq!(s, again);
|
||||||
|
// a DNG's old values are Kelvin already: marked, untouched
|
||||||
|
let dng = SourceInfo { raw: true, as_shot_temp: 5100.0, as_shot_tint: 4.0, ..Default::default() };
|
||||||
|
let mut s = DevelopSettings::default();
|
||||||
|
(s.wb.mode, s.wb.temp, s.wb.tint) = (WbMode::Custom, 3200.0, 10.0);
|
||||||
|
normalize_wb(&mut s, &dng);
|
||||||
|
assert_eq!((s.wb.mode, s.wb.temp, s.wb.tint, s.wb.scale), (WbMode::Custom, 3200.0, 10.0, WbScale::Kelvin));
|
||||||
|
// a rendered photo and a raw without matrices: the scale is cleared
|
||||||
|
for info in [SourceInfo::default(), SourceInfo { raw: true, relative_wb: true, ..Default::default() }] {
|
||||||
|
let mut s = DevelopSettings::default();
|
||||||
|
(s.wb.mode, s.wb.temp, s.wb.scale) = (WbMode::Custom, 5800.0, WbScale::Kelvin);
|
||||||
|
normalize_wb(&mut s, &info);
|
||||||
|
assert_eq!((s.wb.mode, s.wb.temp, s.wb.scale), (WbMode::Custom, 5800.0, WbScale::Legacy));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A dual-illuminant (A / D65) camera colour model, its pixels developed for `temp` / `tint`.
|
/// 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> {
|
fn camera_color(temp: f64, tint: f64) -> std::sync::Arc<crate::CameraColor> {
|
||||||
use lightcraft_color::Mat3;
|
use lightcraft_color::Mat3;
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ pub fn as_shot_white_xy_of(info: &crate::RawInfo) -> Xy {
|
|||||||
as_shot_white(&info.color, info.wb_multipliers)
|
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 {
|
fn as_shot_white(c: &ColorData, wb_multipliers: Option<[f32; 3]>) -> Xy {
|
||||||
if let Some(xy) = c.as_shot_white_xy {
|
if let Some(xy) = c.as_shot_white_xy {
|
||||||
return xy;
|
return xy;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice
|
//! 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`).
|
//! (`docs/camera-preview-colour.md`).
|
||||||
use crate::ColorData;
|
use crate::ColorData;
|
||||||
use lightcraft_color::Mat3;
|
use lightcraft_color::{Mat3, Xy};
|
||||||
|
|
||||||
#[path = "spectral_table.rs"]
|
#[path = "spectral_table.rs"]
|
||||||
mod table;
|
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.
|
/// Every camera in the table.
|
||||||
pub fn cameras() -> &'static [Camera] {
|
pub fn cameras() -> &'static [Camera] {
|
||||||
table::CAMERAS
|
table::CAMERAS
|
||||||
@@ -135,6 +163,60 @@ mod tests {
|
|||||||
assert_eq!(find(Some(""), ""), None);
|
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]
|
#[test]
|
||||||
fn every_row_is_a_usable_dng_profile() {
|
fn every_row_is_a_usable_dng_profile() {
|
||||||
assert_eq!(cameras().len(), 52);
|
assert_eq!(cameras().len(), 52);
|
||||||
|
|||||||
@@ -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;
|
let wb = &mut std::sync::Arc::make_mut(&mut d).wb;
|
||||||
wb.temp = 6500.0;
|
wb.temp = 6500.0;
|
||||||
wb.tint = 0.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());
|
let preview_only = app.session.catalog.photo(id).and_then(|p| p.preview_only.clone());
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ One third of the pixels are held out from both fits. The combined model must red
|
|||||||
|
|
||||||
Previously `SourceRef::load()` discarded the decoder's `SourceInfo`, and render jobs used header-only facts (including a hard-coded RAW WB). Decoded pixels and source facts now travel and remain cached together, including across web-worker renders. Smart previews keep the tone curve in their header (the matrix is already in their pixels), so an offline original renders and exports with the same look; proxies built before this fall back to the generic curve until rebuilt. Pixel-statistics/colour-sampling commands use decoded facts too. Render cache version 6 invalidates old thumbnails and loupe renders.
|
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.
|
Sony's proprietary `ColorMatrix` is not assumed to be a DNG XYZ-to-camera matrix. No Adobe profile/matrix or GPL decoder/calibration source is used. The product remains pure Rust.
|
||||||
|
|
||||||
@@ -121,9 +125,10 @@ A raw starts from the first of these that is available (`camera_preview::startin
|
|||||||
|
|
||||||
The spectral matrices only replace the neutral fallback: a photo whose JPEG fit is accepted (with or without a
|
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
|
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
|
gates or the file has no usable JPEG. White balance of a covered model is in Kelvin through its matrices whichever
|
||||||
of its own, as before, so the catalog's `Photo::relative_wb` and the controls are unchanged. `lightcraft-cli
|
look it got (above, issue #730); for every other raw without matrices of its own it stays relative to the as-shot
|
||||||
calibrate` still pools the uncorrected camera colour of covered models, so a profile can be fitted for them.
|
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,
|
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.
|
is recorded here yet. Whether they should come before the file-local fit for these models is open until one is.
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation:
|
|||||||
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
|
| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` |
|
||||||
| `app.quit` | — | Close the app |
|
| `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
|
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
|
`{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.
|
values greater than 0 and at most 800; pan is the normalized image centre, with coordinates from 0 to 1.
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ A Custom white balance on a raw LightCraft develops relative to its as-shot look
|
|||||||
RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvin: the catalog keeps no
|
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
|
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
|
(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;
|
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
|
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.
|
is not evaluated by the importer; LightCraft's Auto control remains available after migration.
|
||||||
|
|||||||
+1
-1
@@ -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 |
|
| `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 |
|
| `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)) |
|
| `get_develop {id?}` | Full develop-settings JSON, with `process`: the rendering process the photo is on ([process-versions.md](process-versions.md)) |
|
||||||
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable |
|
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. White balance: `values` with `wb.temp` / `wb.tint` is Kelvin on raws with colour matrices (DNG, and cameras LightCraft has matrices for) and relative (6500 = as shot) elsewhere; a `settings.wb` with `temp` / `tint` and no `"scale": "kelvin"` is read on the relative scale on every raw (see [control-protocol.md](control-protocol.md)) |
|
||||||
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
|
| `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` |
|
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true` |
|
||||||
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
||||||
|
|||||||
+5
-5
@@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
|||||||
| S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) |
|
| S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) |
|
||||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||||
| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) |
|
| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) |
|
||||||
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
|
||||||
| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) |
|
| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) |
|
||||||
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
|
||||||
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
|
||||||
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
|
| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) |
|
||||||
| **Total** | 393 | 39 | 84 | 36 | 192/200 (96%) | 142/152 (93%) |
|
| **Total** | 397 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) |
|
||||||
|
|
||||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.0% of 200 · P1 95.7% of 152 · P2 42.9% of 163.
|
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.2% of 163.
|
||||||
<!-- /parity:summary -->
|
<!-- /parity:summary -->
|
||||||
|
|
||||||
## Top gaps
|
## Top gaps
|
||||||
@@ -255,7 +255,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||||||
| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | |
|
| LR-EDIT-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-PRESET | White-balance presets | P0 | ✅ | `cmd:develop.wb` | |
|
||||||
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking |
|
| LR-EDIT-COLOR-WB-PICKER | White-balance eyedropper | P0 | ✅ | `cmd:tool.wbPicker`, `cmd:develop.wbPick` | no magnified loupe while picking |
|
||||||
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`), `crates/raw/src/color.rs` (`rebalance`) | relative scale for non-raw in the UI; raws with a colour matrix (DNG and other raws without a file-local look) are re-developed for the chosen white through the camera's own matrices (camera-space white balance, as Lightroom), and the eyedropper / Auto invert the same model; other photos are adapted (Bradford) |
|
| LR-EDIT-COLOR-TEMP | Temperature | P0 | ✅ | `ctl:wb.temp`, `crates/pipeline/src/local.rs` (`wb_matrix_for`, `legacy_white`), `crates/raw/src/color.rs` (`rebalance`), `crates/raw/src/spectral.rs` (`find_camera`), `crates/catalog/src/model.rs` (`relative_wb_camera`) | Kelvin (2000–50000 K, As Shot read from the file) on raws with colour matrices: in the file (DNG: re-developed for the chosen white through the camera's own matrices, camera-space white balance as Lightroom; the eyedropper / Auto invert the same model) or measured for the camera model (`lightcraft_raw::spectral`, 52 bodies, issue #730: the matrices turn the as-shot multipliers into Kelvin and the slider back, the look stays the file's fit, and the picture is adapted from the as-shot white, Bradford); raws without matrices anywhere and rendered files use the relative −100..+100 scale (6500 K / 0 = as shot). Settings written on the relative scale before a camera's matrices reached it keep their look (`WbScale`). Not verified against Lightroom's Kelvin: on the ILCE-7M3 the matrices read 7–10 % below Sony's dial (issue #730), and whether the matrices or the dial are off is open |
|
||||||
| LR-EDIT-COLOR-TINT | Tint | P0 | ✅ | `ctl:wb.tint`, `docs/tint-direction.md` | DNG / Lightroom sign: negative (left) adds green, positive (right) magenta; XMP `crs:Tint` values render as in Lightroom (issue #188); CPU/GPU regression tests |
|
| LR-EDIT-COLOR-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-VIBRANCE | Vibrance | P0 | ✅ | `ctl:color.vibrance` | |
|
||||||
| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | |
|
| LR-EDIT-COLOR-SATURATION | Saturation | P0 | ✅ | `ctl:color.saturation` | |
|
||||||
|
|||||||
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
## October 2026
|
## October 2026
|
||||||
|
|
||||||
|
### Kelvin white balance for raws whose camera has colour matrices
|
||||||
|
- Raws from the 52 camera models whose colour matrices LightCraft has measured (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,
|
||||||
|
Nikon D850, Canon EOS 5D Mark III and others) now show white balance in Kelvin, as a DNG does and as Lightroom
|
||||||
|
does: As Shot reads the file's white (an ILCE-7M3 sample: 4268 K / +7), the Temp slider runs 2000–50000 K and the
|
||||||
|
Daylight, Cloudy, Shade, Tungsten, Fluorescent and Flash presets set real temperatures (issue #730). The matrices
|
||||||
|
only convert between the camera's multipliers and Kelvin; the colour look is what it was.
|
||||||
|
- Nothing already edited changes: a Custom white balance set on the old relative scale renders pixel for pixel as
|
||||||
|
before, and becomes the Kelvin value with the same look the next time it is edited. A photo imported earlier shows
|
||||||
|
its real As Shot temperature once the file is opened (or Reload reads it).
|
||||||
|
- Lightroom's `crs:Temperature` for these raws is read as the Kelvin it is (sidecars, Lightroom catalogs, presets);
|
||||||
|
raws of other cameras keep the as-shot shift of issue #510. The two Kelvin scales come from different matrices and
|
||||||
|
differ by about 7–10 % on the ILCE-7M3; which one is off is still open.
|
||||||
|
|
||||||
### Activity stack
|
### Activity stack
|
||||||
- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and
|
- 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,
|
folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ application's renderer and is ignored: the photo keeps LightCraft's own process,
|
|||||||
| `Exposure2012` | `light.exposure` | EV, 1:1 |
|
| `Exposure2012` | `light.exposure` | EV, 1:1 |
|
||||||
| `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 |
|
| `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 |
|
||||||
| `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom |
|
| `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 |
|
| `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 |
|
| `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 |
|
||||||
| `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 |
|
| `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 |
|
||||||
|
|||||||
Reference in New Issue
Block a user