mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
Merge PR #540
This commit is contained in:
@@ -296,6 +296,14 @@ pub struct Analysis {
|
||||
pub best: bool,
|
||||
}
|
||||
|
||||
/// Raw formats whose readers have vendor white-balance multipliers but no measured camera
|
||||
/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means
|
||||
/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's
|
||||
/// `fileFormat` name, in any case. See [`Photo::relative_wb`].
|
||||
pub fn relative_wb_format(format: &str) -> bool {
|
||||
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo {
|
||||
Photo {
|
||||
@@ -341,8 +349,7 @@ impl Photo {
|
||||
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and
|
||||
/// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files).
|
||||
pub fn relative_wb(&self) -> bool {
|
||||
self.develops_raw()
|
||||
&& ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
|
||||
self.develops_raw() && relative_wb_format(&self.format)
|
||||
}
|
||||
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
|
||||
/// default preset applied on top of them ([`Photo::import_look`]).
|
||||
|
||||
+149
-23
@@ -18,6 +18,43 @@ use serde_json::{Map, Value, json};
|
||||
/// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]).
|
||||
pub type Props = BTreeMap<String, Vec<String>>;
|
||||
|
||||
/// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw
|
||||
/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its
|
||||
/// as-shot look (rendered files, and raws whose readers have no illuminant,
|
||||
/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white
|
||||
/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale.
|
||||
Any,
|
||||
/// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our
|
||||
/// relative scale.
|
||||
Rendered,
|
||||
/// A raw developed with an absolute white balance (DNG and other files with a measured
|
||||
/// illuminant): Kelvin as written.
|
||||
RawAbsolute,
|
||||
/// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]).
|
||||
RawRelative,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// The target for a file of this media kind and format (its extension or Lightroom's
|
||||
/// `fileFormat` name); `preview_only` for a raw that can't be decoded yet.
|
||||
pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target {
|
||||
if kind != lightcraft_catalog::MediaKind::Raw || preview_only {
|
||||
Target::Rendered
|
||||
} else if lightcraft_catalog::relative_wb_format(format) {
|
||||
Target::RawRelative
|
||||
} else {
|
||||
Target::RawAbsolute
|
||||
}
|
||||
}
|
||||
/// The target for a photo in the catalog.
|
||||
pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target {
|
||||
Target::for_file(p.kind, &p.format, p.preview_only.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// Band names as they appear in `crs:` field names, in our mixer order.
|
||||
const CRS_BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"];
|
||||
|
||||
@@ -69,6 +106,8 @@ pub fn has_adjustments(props: &Props) -> bool {
|
||||
thread_local! {
|
||||
/// Keys read by [`to_partial`] while [`to_partial_report`] runs.
|
||||
static READ: std::cell::RefCell<Option<std::collections::BTreeSet<String>>> = const { std::cell::RefCell::new(None) };
|
||||
/// Adjustments [`to_partial`] read but deliberately left out, for the report.
|
||||
static SKIPPED: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
fn note(k: &str) {
|
||||
@@ -79,6 +118,14 @@ fn note(k: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
fn skipped(what: &str) {
|
||||
SKIPPED.with(|r| {
|
||||
if let Some(s) = r.borrow_mut().as_mut() {
|
||||
s.push(what.to_string());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> {
|
||||
note(k);
|
||||
props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty())
|
||||
@@ -138,10 +185,8 @@ fn curve(props: &Props, k: &str) -> Option<Value> {
|
||||
|
||||
/// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object.
|
||||
///
|
||||
/// `raw`: the target is a raw file (absolute Kelvin white balance) — `Some(false)` prefers the
|
||||
/// relative `Incremental*` white balance used for rendered files, `None` (presets) takes whichever
|
||||
/// is present, absolute first.
|
||||
pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
/// `target` decides how the white balance is read (see [`Target`]).
|
||||
pub fn to_partial(props: &Props, target: Target) -> Value {
|
||||
let mut out = Value::Object(Map::new());
|
||||
let o = &mut out;
|
||||
let n = |o: &mut Value, crs: &str, path: &str| {
|
||||
@@ -200,10 +245,24 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
let rel = num(props, "crs:IncrementalTemperature")
|
||||
.or(num(props, "crs:IncrementalTint").map(|_| 0.0))
|
||||
.map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint")));
|
||||
let wb = match raw {
|
||||
Some(true) => abs,
|
||||
Some(false) => rel.or(abs),
|
||||
None => abs.or(rel),
|
||||
// A Kelvin value for something we develop relative to its as-shot look: the other app's
|
||||
// as-shot white (`AsShotTemperature` / `AsShotTint`, written next to the edit) is the
|
||||
// reference, so the edit is the same mired shift from ours (issue #510).
|
||||
let shifted = || {
|
||||
let (t, tint) = abs?;
|
||||
let as_shot = num(props, "crs:AsShotTemperature").filter(|k| *k > 0.0)?;
|
||||
if t <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let r = (1e6 / as_shot - 1e6 / t) / 0.8;
|
||||
let tint = tint.map(|v| v - num(props, "crs:AsShotTint").unwrap_or(0.0));
|
||||
Some((rel_to_kelvin(r), tint))
|
||||
};
|
||||
let wb = match target {
|
||||
Target::RawAbsolute => abs,
|
||||
Target::Any => abs.or(rel),
|
||||
Target::Rendered => rel.or_else(shifted).or(abs),
|
||||
Target::RawRelative => rel.or_else(shifted),
|
||||
};
|
||||
match (mode, wb) {
|
||||
(Some(m), _) if m != "custom" => put(o, "wb.mode", json!(m)),
|
||||
@@ -214,6 +273,13 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
put(o, "wb.tint", json!(tint));
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// the photo stays As Shot (ΔE 12 there) and the report says so
|
||||
(_, None) if target == Target::RawRelative && abs.is_some() => {
|
||||
put(o, "wb.mode", json!("asShot"));
|
||||
skipped("Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)");
|
||||
}
|
||||
(Some(m), None) => put(o, "wb.mode", json!(m)),
|
||||
(None, None) => {}
|
||||
}
|
||||
@@ -375,11 +441,12 @@ pub fn to_partial(props: &Props, raw: Option<bool>) -> Value {
|
||||
/// With `values` (the packet's structured properties), local corrections become masks too
|
||||
/// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that
|
||||
/// can't be carried over are reported as `Mask: <kind>`.
|
||||
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option<bool>, aspect: f64) -> (Value, Vec<String>) {
|
||||
pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, target: Target, aspect: f64) -> (Value, Vec<String>) {
|
||||
READ.with(|r| *r.borrow_mut() = Some(Default::default()));
|
||||
let mut out = to_partial(props, raw);
|
||||
SKIPPED.with(|r| *r.borrow_mut() = Some(Vec::new()));
|
||||
let mut out = to_partial(props, target);
|
||||
let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
let mut mask_skips = Vec::new();
|
||||
let mut mask_skips = SKIPPED.with(|r| r.borrow_mut().take()).unwrap_or_default();
|
||||
if let Some(values) = values {
|
||||
let (masks, skipped) = crate::crs_masks::masks(values, aspect);
|
||||
if !masks.is_empty() {
|
||||
@@ -387,7 +454,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
}
|
||||
if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) {
|
||||
read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string()));
|
||||
mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect();
|
||||
mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}")));
|
||||
}
|
||||
}
|
||||
// fields that only switch a panel on/off or name things: not adjustments by themselves; an
|
||||
@@ -421,7 +488,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option<Preset> {
|
||||
let d = lightcraft_meta::parse_xmp(xmp).ok()?;
|
||||
let props = &d.properties;
|
||||
let settings = to_partial(props, None);
|
||||
let settings = to_partial(props, Target::Any);
|
||||
if settings.as_object().is_none_or(Map::is_empty) {
|
||||
return None;
|
||||
}
|
||||
@@ -474,7 +541,7 @@ mod tests {
|
||||
fn maps_common_fields() {
|
||||
let p = props(SIDECAR);
|
||||
assert!(has_adjustments(&p));
|
||||
let partial = to_partial(&p, Some(true));
|
||||
let partial = to_partial(&p, Target::RawAbsolute);
|
||||
let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0);
|
||||
assert_eq!(s.wb.mode, WbMode::Custom);
|
||||
assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0));
|
||||
@@ -516,7 +583,7 @@ mod tests {
|
||||
fn only_present_fields_are_in_the_partial() {
|
||||
let x = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
xmlns:c="http://ns.adobe.com/camera-raw-settings/1.0/" c:Vibrance="+30" c:GrainAmount="10"/></rdf:RDF>"#;
|
||||
let partial = to_partial(&props(x), None);
|
||||
let partial = to_partial(&props(x), Target::Any);
|
||||
assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}}));
|
||||
}
|
||||
|
||||
@@ -531,12 +598,12 @@ mod tests {
|
||||
<crs:Temperature>4800</crs:Temperature>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let p = props(x);
|
||||
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(false)), 1.0);
|
||||
let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0);
|
||||
assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw);
|
||||
assert_eq!(rendered.bw_mix.blue, -35.0);
|
||||
assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0);
|
||||
assert_eq!(rendered.wb.tint, -6.0);
|
||||
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(true)), 1.0);
|
||||
let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0);
|
||||
assert_eq!(raw.wb.temp, 4800.0);
|
||||
}
|
||||
|
||||
@@ -545,13 +612,72 @@ mod tests {
|
||||
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="Daylight" crs:Temperature="5500" crs:Tint="10" crs:AlreadyApplied="True"/>"#;
|
||||
let p = props(x);
|
||||
assert_eq!(to_partial(&p, Some(true)), json!({"wb": {"mode": "daylight"}}));
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}}));
|
||||
assert!(!has_adjustments(&p));
|
||||
let bookkeeping = 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:Version="1" crs:HasSettings="True"/>"#;
|
||||
assert!(!has_adjustments(&props(bookkeeping)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() {
|
||||
// Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white
|
||||
// of 3650 K / −2 (written for this test from the numbers in the issue).
|
||||
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="3578" crs:Tint="-5" crs:AsShotTemperature="3650" crs:AsShotTint="-2"/>"#;
|
||||
let p = props(x);
|
||||
let rel = to_partial(&p, Target::RawRelative);
|
||||
// 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale
|
||||
let t = rel["wb"]["temp"].as_f64().unwrap();
|
||||
assert!((t - 6275.0).abs() < 5.0, "{t}");
|
||||
assert_eq!(rel["wb"]["tint"], -3.0);
|
||||
assert_eq!(rel["wb"]["mode"], "custom");
|
||||
// a rendered file without `Incremental*` fields shifts the same way; a raw with a measured
|
||||
// illuminant (and a preset) keeps the Kelvin value
|
||||
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]);
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
||||
assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0);
|
||||
// the as-shot fields are a reference, not an adjustment: nothing to report
|
||||
assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty());
|
||||
// no tint next to the as-shot tint: the custom tint is taken as the shift itself
|
||||
let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, ""));
|
||||
assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() {
|
||||
// The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos:
|
||||
// 3578 K read on the relative scale would be a large blue shift (issue #510)
|
||||
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="3578" crs:Tint="-5" crs:Exposure2012="+0.3"/>"#;
|
||||
let p = props(x);
|
||||
let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5);
|
||||
assert_eq!(partial["wb"], json!({"mode": "asShot"}));
|
||||
assert_eq!(partial["light"]["exposure"], 0.3);
|
||||
assert_eq!(unmapped.len(), 1, "{unmapped:?}");
|
||||
assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}");
|
||||
// a raw with a measured illuminant and a rendered file read it as before, and report nothing
|
||||
assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0);
|
||||
assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0);
|
||||
assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty());
|
||||
// an unusable reference (zero) counts as none
|
||||
let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#));
|
||||
assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_follows_the_file() {
|
||||
use lightcraft_catalog::MediaKind;
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative);
|
||||
// Lightroom's `fileFormat` name for proprietary raws
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative);
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute);
|
||||
// a raw shown from its embedded preview develops like a rendered file
|
||||
assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered);
|
||||
assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xmp_preset_with_name_and_group() {
|
||||
// Written for this test: a minimal preset-shaped packet with localized name/group.
|
||||
@@ -594,15 +720,15 @@ mod tests {
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
};
|
||||
let alone = to_partial(&props(&packet("")), Some(true));
|
||||
let alone = to_partial(&props(&packet("")), Target::RawAbsolute);
|
||||
assert!(alone.pointer("/curve/green").is_none(), "{alone}");
|
||||
let master = "<crs:ToneCurvePV2012><rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq></crs:ToneCurvePV2012>";
|
||||
let with = to_partial(&props(&packet(master)), Some(true));
|
||||
let with = to_partial(&props(&packet(master)), Target::RawAbsolute);
|
||||
assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}");
|
||||
}
|
||||
|
||||
fn unmapped(x: &str) -> Vec<String> {
|
||||
to_partial_report(&props(x), None, None, 1.5).1
|
||||
to_partial_report(&props(x), None, Target::Any, 1.5).1
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -622,8 +748,8 @@ mod tests {
|
||||
let p = props(&preset_packet("", ""));
|
||||
let mut d = DevelopSettings::default();
|
||||
d.optics.lens_profile = true;
|
||||
assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile);
|
||||
assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile);
|
||||
let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#));
|
||||
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile);
|
||||
assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ mod tests {
|
||||
|
||||
fn settings() -> (DevelopSettings, Vec<String>) {
|
||||
let d = lightcraft_meta::parse_xmp(PACKET).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5);
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
(DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped)
|
||||
}
|
||||
|
||||
|
||||
@@ -899,7 +899,8 @@ impl ImportJob {
|
||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) {
|
||||
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some());
|
||||
let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) {
|
||||
Ok(sc) => Some(sc),
|
||||
Err(e) => {
|
||||
log::warn!("import {path}: XMP: {e}");
|
||||
|
||||
@@ -478,7 +478,13 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec<String>), String> {
|
||||
fn mapped_settings(
|
||||
text: &str,
|
||||
target: crate::crs::Target,
|
||||
aspect: f64,
|
||||
orientation: Orientation,
|
||||
stored_aspect: f64,
|
||||
) -> Result<(Value, Vec<String>), String> {
|
||||
let mut root = crate::preset_import::parse_lua(text)?;
|
||||
let deferred = strip_lua_sentinels(&mut root);
|
||||
let (mut props, values) = crate::preset_import::lua_settings_props(&root)?;
|
||||
@@ -488,7 +494,7 @@ fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation,
|
||||
if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) {
|
||||
props.insert("crs:HasCrop".into(), vec!["True".into()]);
|
||||
}
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect);
|
||||
let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect);
|
||||
catalog_crop(&mut partial, orientation, stored_aspect);
|
||||
if deferred > 0 {
|
||||
unknown.push("Deferred Lightroom adjustments (-999999)".into());
|
||||
@@ -717,7 +723,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
preserved += 1;
|
||||
}
|
||||
if !src.xmp.is_empty() {
|
||||
match crate::sidecar::parse_sidecar(&src.xmp, p.kind == lightcraft_catalog::MediaKind::Raw) {
|
||||
match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) {
|
||||
Ok(mut sc) => {
|
||||
if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop {
|
||||
strip_xmp_sentinels(partial);
|
||||
@@ -745,7 +751,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64;
|
||||
match mapped_settings(
|
||||
&src.settings,
|
||||
p.kind == lightcraft_catalog::MediaKind::Raw,
|
||||
crate::crs::Target::for_photo(&p),
|
||||
p.width.max(1) as f64 / p.height.max(1) as f64,
|
||||
orientation,
|
||||
stored_aspect,
|
||||
@@ -936,7 +942,10 @@ mod tests {
|
||||
cyclic.collections[0].insert("parent".into(), json!(10));
|
||||
assert!(apply(&mut s, cyclic, false).is_err());
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal, 1.5).is_err());
|
||||
assert!(
|
||||
mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compressed_xmp_is_bounded_and_settings_are_data_only() {
|
||||
@@ -946,17 +955,41 @@ mod tests {
|
||||
assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet));
|
||||
encoded[0] = 0xff;
|
||||
assert!(xmp(Some(&json!(encoded))).is_err());
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(
|
||||
"s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }",
|
||||
crate::crs::Target::RawAbsolute,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], 0.75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() {
|
||||
// Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed
|
||||
// relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin
|
||||
let settings =
|
||||
"s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }";
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}");
|
||||
assert_eq!(partial["light"]["exposure"], 0.5);
|
||||
assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}");
|
||||
let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(
|
||||
(partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()),
|
||||
(Some("custom"), Some(3578.0), Some(-5.0))
|
||||
);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_crops_without_has_crop_are_imported() {
|
||||
// Lightroom Classic catalogs store crop edges and angle but never `HasCrop`
|
||||
let (partial, unknown) = mapped_settings(
|
||||
"s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }",
|
||||
true,
|
||||
crate::crs::Target::RawAbsolute,
|
||||
1.5,
|
||||
Orientation::Normal,
|
||||
1.5,
|
||||
@@ -970,11 +1003,11 @@ mod tests {
|
||||
assert_eq!(crop["angle"], 0.0);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}");
|
||||
// an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's)
|
||||
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert_eq!(partial["crop"]["geometry"]["angle"], 1.5);
|
||||
assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}");
|
||||
// no crop fields: the crop stays untouched
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
@@ -998,7 +1031,7 @@ mod tests {
|
||||
fn catalog_crops_follow_the_photo_orientation() {
|
||||
let crop = |o: Orientation| {
|
||||
let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }";
|
||||
let (partial, _) = mapped_settings(settings, true, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let g = &partial["crop"]["geometry"];
|
||||
let r = &g["rect"];
|
||||
[r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9)
|
||||
@@ -1011,13 +1044,13 @@ mod tests {
|
||||
assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]);
|
||||
// a mirror turns the straighten the other way
|
||||
let angle = |o: Orientation| {
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", true, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
partial["crop"]["geometry"]["angle"].as_f64().unwrap()
|
||||
};
|
||||
assert_eq!(angle(Orientation::Rotate90), -2.0);
|
||||
assert_eq!(angle(Orientation::FlipH), 2.0);
|
||||
// no crop: nothing to orient
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Rotate90, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap();
|
||||
assert!(partial.get("crop").is_none());
|
||||
}
|
||||
|
||||
@@ -1043,7 +1076,7 @@ mod tests {
|
||||
for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] {
|
||||
for angle in [6.5, -6.5] {
|
||||
let g = CropGeometry { angle, ..g };
|
||||
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), true, 1.5, o, 1.5).unwrap();
|
||||
let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap();
|
||||
let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap();
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(
|
||||
@@ -1073,7 +1106,7 @@ mod tests {
|
||||
(6100.0, 9150.0),
|
||||
),
|
||||
] {
|
||||
let (partial, _) = mapped_settings(settings, true, 1.5, o, 9504.0 / 6336.0).unwrap();
|
||||
let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap();
|
||||
let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap();
|
||||
let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) };
|
||||
let (w, h) = (r.width() * ow, r.height() * oh);
|
||||
@@ -1112,7 +1145,9 @@ mod tests {
|
||||
assert_eq!(p.develop.light.shadows, 65.0);
|
||||
assert_eq!(p.develop.light.blacks, -22.0);
|
||||
assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred"))));
|
||||
let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0, Orientation::Normal, 1.5).unwrap();
|
||||
let (partial, _) =
|
||||
mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5)
|
||||
.unwrap();
|
||||
assert_eq!(partial["light"]["exposure"], -5.0);
|
||||
assert_eq!(partial["light"]["contrast"], -100.0);
|
||||
}
|
||||
|
||||
@@ -464,7 +464,7 @@ pub fn group_from_dir(dir: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option<String>, from_photo: bool) -> Option<Imported> {
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT);
|
||||
let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT);
|
||||
if from_photo && let Some(o) = settings.as_object_mut() {
|
||||
// a photo's own framing and absolute white balance don't belong in a look
|
||||
o.remove("crop");
|
||||
|
||||
@@ -114,8 +114,10 @@ pub enum DevelopPatch {
|
||||
Partial(Value),
|
||||
}
|
||||
|
||||
/// Parse a sidecar / XMP packet. `raw` selects absolute (raw) vs relative white balance for `crs:`.
|
||||
pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, String> {
|
||||
/// Parse a sidecar / XMP packet. `target` selects how a `crs:` white balance is read
|
||||
/// ([`crate::crs::Target`]: absolute Kelvin for raws with a measured illuminant, a shift from the
|
||||
/// as-shot white for everything developed relative to it).
|
||||
pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Result<SidecarData, String> {
|
||||
let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?;
|
||||
let m = &d.metadata;
|
||||
let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned();
|
||||
@@ -162,7 +164,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
|
||||
out.develop = match full {
|
||||
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
|
||||
None if crate::crs::has_adjustments(&d.properties) => {
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), Some(raw), crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -475,7 +477,7 @@ impl Session {
|
||||
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
|
||||
let Some(orig) = file_path(p) else { return Ok(None) };
|
||||
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
|
||||
let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw)
|
||||
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p))
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?
|
||||
.resolve_label(&self.catalog);
|
||||
let mut q = (**p).clone();
|
||||
@@ -553,7 +555,7 @@ mod tests {
|
||||
fn packet_roundtrip_restores_everything() {
|
||||
let p = photo();
|
||||
let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new());
|
||||
let sc = parse_sidecar(&x, false).unwrap();
|
||||
let sc = parse_sidecar(&x, crate::crs::Target::Rendered).unwrap();
|
||||
assert_eq!(sc.rating, Some(4));
|
||||
assert_eq!(sc.flag, Some(Flag::Pick));
|
||||
assert_eq!(sc.label, Some(Some(ColorLabel::Purple)));
|
||||
@@ -572,7 +574,7 @@ mod tests {
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
xmp:Rating="-1" xmp:Label="Green" crs:Exposure2012="-0.40" crs:Vibrance="+12"/></rdf:RDF></x:xmpmeta>"#;
|
||||
let sc = parse_sidecar(x, true).unwrap();
|
||||
let sc = parse_sidecar(x, crate::crs::Target::RawAbsolute).unwrap();
|
||||
assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green))));
|
||||
let mut p = photo();
|
||||
let before = p.develop.clone();
|
||||
|
||||
@@ -355,9 +355,9 @@ fn label_sets_and_xmp_label_names() {
|
||||
p.label = Some(ColorLabel::Green);
|
||||
let x = crate::sidecar::sidecar_packet(&p, &s.catalog);
|
||||
assert!(x.contains("Approved"), "{x}");
|
||||
let sc = crate::sidecar::parse_sidecar(&x, false).unwrap().resolve_label(&s.catalog);
|
||||
let sc = crate::sidecar::parse_sidecar(&x, crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog);
|
||||
assert_eq!(sc.label, Some(Some(ColorLabel::Green)));
|
||||
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), false).unwrap().resolve_label(&s.catalog);
|
||||
let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog);
|
||||
assert_eq!(plain.label, Some(Some(ColorLabel::Blue)), "colour names still work");
|
||||
s.execute("label.deleteSet", &json!({"name": "Studio"})).unwrap();
|
||||
assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay");
|
||||
|
||||
@@ -94,7 +94,8 @@ fn auto_write_and_naming_preference() {
|
||||
// a slider drag writes once, at the end
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap();
|
||||
let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), false).unwrap().develop {
|
||||
let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().develop
|
||||
{
|
||||
Some(crate::sidecar::DevelopPatch::Full(d)) => d.light.exposure,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
|
||||
@@ -41,6 +41,11 @@ models, some masking/retouch fields and process-version algorithms are not repro
|
||||
fields are reported. When a recovery archive is saved, it retains source settings/history/snapshots
|
||||
and original smart-collection rules. Smart collections become regular albums with current
|
||||
membership. Archived history and snapshots are source data, not native LightCraft history yet.
|
||||
A Custom white balance on a raw LightCraft develops relative to its as-shot look (ARW, NEF, RW2,
|
||||
RAF, CR3, CR2, PEF, SRW; Lightroom names them all `RAW`) can't be taken as Kelvin: the catalog keeps no
|
||||
as-shot white for Custom photos, so the photo stays As Shot and `Temperature, Tint` are reported
|
||||
(issue #510; a DNG keeps the Kelvin value, and an XMP packet with `crs:AsShotTemperature` is
|
||||
shifted by the same mired difference, see [xmp-interop.md](xmp-interop.md)).
|
||||
Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings;
|
||||
it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone
|
||||
is not evaluated by the importer; LightCraft's Auto control remains available after migration.
|
||||
|
||||
+2
-2
@@ -228,7 +228,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
||||
| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own |
|
||||
| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`) | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; no embedded map, no geocoding |
|
||||
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw) |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510) |
|
||||
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
|
||||
|
||||
## F. Edit panel — global adjustments (EDIT)
|
||||
@@ -739,7 +739,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
||||
| LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N |
|
||||
| LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo |
|
||||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs |
|
||||
| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs |
|
||||
| LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) |
|
||||
| LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |
|
||||
| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here |
|
||||
|
||||
+2
-2
@@ -107,8 +107,8 @@ names are read (e.g. `Exposure2012`, not the older `Exposure`).
|
||||
| `Exposure2012` | `light.exposure` | EV, 1:1 |
|
||||
| `Contrast2012`, `Highlights2012`, `Shadows2012`, `Whites2012`, `Blacks2012` | `light.contrast` … `light.blacks` | −100..100, 1:1 |
|
||||
| `WhiteBalance` | `wb.mode` | `As Shot`, `Auto`, `Daylight`, `Cloudy`, `Shade`, `Tungsten`, `Fluorescent`, `Flash`; other names → custom |
|
||||
| `Temperature`, `Tint` | `wb.temp`, `wb.tint` | Kelvin / tint for raw files (and presets) |
|
||||
| `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider) |
|
||||
| `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) |
|
||||
| `IncrementalTemperature`, `IncrementalTint` | `wb.temp`, `wb.tint` | rendered files: −100..100 on our relative scale (mired shift around 6500 K, same as the Temp slider); a rendered file with only `Temperature` shifts from `AsShotTemperature` like the raws above when it is present |
|
||||
| `Vibrance`, `Saturation` | `color.vibrance`, `color.saturation` | 1:1 |
|
||||
| `Texture`, `Clarity2012`, `Dehaze` | `effects.texture`, `effects.clarity`, `effects.dehaze` | 1:1 |
|
||||
| `HueAdjustment<Band>`, `SaturationAdjustment<Band>`, `LuminanceAdjustment<Band>` | `mixer.<band>.hue/sat/lum` | bands Red, Orange, Yellow, Green, Aqua, Blue, Purple, Magenta |
|
||||
|
||||
Reference in New Issue
Block a user