diff --git a/ROADMAP.md b/ROADMAP.md index 4a6b101a..836fa92c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,7 +164,8 @@ The milestone estimates in the table above were made before work started and are published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local Laplacian filters, PatchMatch and HEVC (HEIC). - **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY) - turns it into measured comparisons against local-only Lightroom references. + turns it into measured comparisons against local-only Lightroom references. Retuning changes existing edits, so it + ships as a new process version (`docs/process-versions.md`): photos keep their look until they are updated. ## Raw format coverage and known gaps diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 957f5b68..d5273af2 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -345,15 +345,19 @@ impl Photo { && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's - /// default preset applied on top of them ([`Photo::import_look`]). + /// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults, + /// in the photo's own rendering process. pub fn import_defaults(&self) -> DevelopSettings { match &self.import_look { - Some(l) => (**l).clone(), + Some(l) => DevelopSettings { process: self.develop.process, ..(**l).clone() }, None => self.camera_defaults(), } } /// The built-in defaults for this photo, before any user default preset: raws start from - /// their as-shot white balance; embedded lens corrections on when the file has them. + /// their as-shot white balance; embedded lens corrections on when the file has them. They + /// carry the photo's own rendering process ([`lightcraft_develop::ProcessVersion`]): which + /// process a photo renders with is not an edit, so comparisons with its defaults (edited? + /// still as imported?) don't change when a newer process exists. A new photo has the latest. pub fn camera_defaults(&self) -> DevelopSettings { let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb }; let mut d = match wb { @@ -363,6 +367,7 @@ impl Photo { if self.embedded_lens.is_some() { d.optics.lens_profile = true; } + d.process = self.develop.process; d } /// In the library: not deleted and not only browsed (Local). @@ -439,6 +444,32 @@ mod edited_tests { assert!(!p.is_edited(), "a full reset is unedited too"); } + #[test] + fn the_process_a_photo_renders_with_is_not_an_edit() { + use lightcraft_develop::ProcessVersion; + // a photo on another process than the latest (a V1 photo once a later process is the + // latest, or a photo saved by a newer LightCraft) + let other = ProcessVersion(ProcessVersion::LATEST.0 + 1); + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.dng", "DNG", 10, 10, "2026-10-01T00:00:00"); + p.kind = MediaKind::Raw; + p.as_shot_wb = Some((5200.0, 4.0)); + assert_eq!(p.camera_defaults().process, ProcessVersion::LATEST, "a new photo gets the latest"); + p.develop = Arc::new(DevelopSettings { process: other, ..p.import_defaults() }); + assert_eq!((p.camera_defaults().process, p.import_defaults().process), (other, other)); + assert!(!p.is_edited(), "still as imported"); + // with a default preset's look as well + let mut look = DevelopSettings::for_raw(5200.0, 4.0); + look.light.exposure = 0.3; + p.import_look = Some(Arc::new(look.clone())); + p.develop = Arc::new(DevelopSettings { process: other, ..look }); + assert_eq!(p.import_defaults().process, other); + assert!(!p.is_edited()); + let mut d = (*p.develop).clone(); + d.light.exposure = 1.0; + p.develop = Arc::new(d); + assert!(p.is_edited(), "an edit still is one"); + } + #[test] fn supported_raws_use_relative_white_balance() { for (name, format, relative) in [ diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 0594b6d8..9c84a84a 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -326,3 +326,44 @@ fn failed_streamed_write_keeps_the_old_file() { assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new"); let _ = std::fs::remove_dir_all(&dir); } + +/// A damaged rendering process in a saved photo (snapshot or log) reads as V1 and costs nothing +/// else: the photo and its edits load (`docs/process-versions.md`). +#[test] +fn a_damaged_process_value_loads_as_v1_with_the_edits() { + use lightcraft_develop::ProcessVersion; + let mut c = Catalog::new(); + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00"); + p.develop = Arc::new(DevelopSettings { + process: ProcessVersion(7), + light: lightcraft_develop::Light { exposure: 0.5, ..Default::default() }, + ..Default::default() + }); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let snapshot = c.to_snapshot(); + assert!(snapshot.contains(r#""process":7"#)); + let snapshot = format!( + "{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":0,\"catalog\":{}}}\n", + crate::journal::VERSION, + snapshot.replace(r#""process":7"#, r#""process":-1"#) + ); + let m = MemStore::new(); + m.set(SNAPSHOT, snapshot.into_bytes()); + let (_, loaded, _) = open(&m); + let d = &loaded.photo(id).unwrap().develop; + assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5)); + // and in a (CRC-valid) log record + let edit = DevelopSettings { + process: ProcessVersion(7), + light: lightcraft_develop::Light { exposure: 0.8, ..Default::default() }, + ..Default::default() + }; + let op = Op::SetDevelop { id, settings: Arc::new(edit), label: "Edit".into(), edited: None }; + let body = serde_json::to_string(&op).unwrap().replace(r#""process":7"#, r#""process":"seven""#); + assert!(body.contains("seven")); + m.set(LOG, format!("{{\"seq\":1,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes()); + let (_, loaded, _) = open(&m); + let d = &loaded.photo(id).unwrap().develop; + assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8)); +} diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index b7c95251..c9e158c6 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -56,11 +56,13 @@ impl DevelopSettings { h } - /// True if nothing differs from a fresh default (ignoring white balance "as shot" values). + /// True if nothing differs from a fresh default (ignoring white balance "as shot" values, and + /// the rendering process: that is not an edit). pub fn is_unedited(&self) -> bool { let mut a = self.clone(); let d = DevelopSettings::default(); a.wb = d.wb; + a.process = d.process; a == d } @@ -130,6 +132,9 @@ impl DevelopSettings { } } +#[cfg(test)] +mod tests_process; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 64bb116c..1708e4fd 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -3,6 +3,10 @@ //! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it //! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current //! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]). +//! +//! The rendering process ([`crate::ProcessVersion`]) is in no group: copy, paste, sync and presets +//! made from a photo never carry it, so the photos they change keep their own. A partial that names +//! `process` itself (Apply Settings JSON) sets it like any other field. use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 5f0db8ab..88f15bec 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -1,15 +1,110 @@ //! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so -//! older/newer files load (unknown fields are ignored, missing fields take defaults). +//! older/newer files load (unknown fields are ignored, missing fields take defaults). The one +//! exception is `process`: missing (or unreadable), it is V1, the rendering those older files were +//! made with. use lightcraft_geom::{CropGeometry, Homography, Orientation, Point}; use serde::{Deserialize, Serialize}; pub const SCHEMA_VERSION: u32 = 1; +/// The rendering process a photo's settings are interpreted by (what Lightroom calls the process +/// version). A change that would alter how existing settings render ships as a new process, so a +/// photo keeps its look until someone updates it (`develop.updateProcess`). See +/// `docs/process-versions.md`. +/// +/// Stored as a plain number: one written by a newer LightCraft loads, is kept as it is, and +/// renders with the newest process this build knows ([`ProcessVersion::process`]). Reading is +/// lenient: a whole number from 0 to 2^32 - 1 (also written as `2.0`) is that process; anything +/// else (negative, fractional, too large, text, null, a list…) reads as V1, like a missing field, +/// so a damaged value never costs the rest of the settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct ProcessVersion(pub u32); + +impl<'de> Deserialize<'de> for ProcessVersion { + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Whole(u64), + Number(f64), + Other(serde::de::IgnoredAny), + } + Ok(match Wire::deserialize(d)? { + Wire::Whole(n) => u32::try_from(n).map_or_else(|_| ProcessVersion::legacy(), ProcessVersion), + Wire::Number(x) if x.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&x) => ProcessVersion(x as u32), + Wire::Number(_) | Wire::Other(_) => ProcessVersion::legacy(), + }) + } +} + +impl ProcessVersion { + /// The first process: LightCraft's rendering from before process versions existed. Frozen. + pub const V1: ProcessVersion = Process::V1.version(); + /// The process new photos and Reset get. + pub const LATEST: ProcessVersion = Process::LATEST.version(); + + /// The process of settings saved without one (catalogs, snapshots, XMP sidecars and browser + /// libraries written before process versions existed): V1, whatever [`Self::LATEST`] becomes. + pub fn legacy() -> ProcessVersion { + ProcessVersion::V1 + } + + /// Saved settings leave the field out for V1, so files and preview-cache keys of settings made + /// before process versions existed stay byte for byte what they were. + pub fn is_legacy(&self) -> bool { + *self == ProcessVersion::legacy() + } + + /// The process this build renders with: the newest one it knows that isn't newer than this + /// number (a number from a newer LightCraft renders with [`Process::LATEST`]; one below V1, + /// which no LightCraft writes, with V1). + pub fn process(self) -> Process { + Process::ALL.into_iter().rev().find(|p| p.version() <= self).unwrap_or(Process::V1) + } + + /// A process this build implements. + pub fn is_known(self) -> bool { + Process::ALL.iter().any(|p| p.version() == self) + } + + /// Older than [`Self::LATEST`]: Update to Current Process applies. + pub fn is_outdated(self) -> bool { + self < ProcessVersion::LATEST + } +} + +/// A rendering process this build implements. Matches on it are exhaustive, so adding a process +/// makes the compiler point at every stage whose behaviour depends on it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Process { + /// The rendering of LightCraft before process versions existed (2026-10). + V1, +} + +impl Process { + /// Every process, oldest first. + pub const ALL: [Process; 1] = [Process::V1]; + pub const LATEST: Process = Process::V1; + + pub const fn version(self) -> ProcessVersion { + match self { + Process::V1 => ProcessVersion(1), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DevelopSettings { + /// Schema version of this structure (not the rendering process, see `process`). pub version: u32, + /// The rendering process ([`ProcessVersion`]): the latest for new settings, V1 for settings + /// saved without the field (the field-level default: not the struct's `Default`) or with a + /// value that isn't a process number. + #[serde(default = "ProcessVersion::legacy", skip_serializing_if = "ProcessVersion::is_legacy")] + pub process: ProcessVersion, pub profile: Profile, pub treatment: Treatment, pub wb: WhiteBalance, @@ -44,6 +139,7 @@ impl Default for DevelopSettings { fn default() -> Self { Self { version: SCHEMA_VERSION, + process: ProcessVersion::LATEST, profile: Profile::default(), treatment: Treatment::Color, wb: WhiteBalance::default(), diff --git a/crates/develop/src/tests_process.rs b/crates/develop/src/tests_process.rs new file mode 100644 index 00000000..f89ebfa1 --- /dev/null +++ b/crates/develop/src/tests_process.rs @@ -0,0 +1,162 @@ +//! Process versions: settings saved before they existed stay on V1, new settings get the latest, +//! and numbers this build doesn't know load without harm. + +use serde_json::{Value, json}; + +use crate::*; + +/// Complete settings as LightCraft wrote them before process versions existed (main at 8f924a9: +/// `serde_json::to_string` of a raw photo with tone, colour, curve, grading, effects and a radial +/// mask), one top-level key per line. There is no `process` field. +const LEGACY: &str = r#" +{ + "version": 1, + "profile": {"id": "lc.color", "amount": 100.0}, + "treatment": "color", + "wb": {"mode": "asShot", "temp": 5200.0, "tint": 4.0}, + "light": {"exposure": 0.4, "contrast": 30.0, "highlights": -60.0, "shadows": 40.0, "whites": 15.0, "blacks": -20.0}, + "curve": {"highlights": -10.0, "lights": 0.0, "darks": 10.0, "shadows": 0.0, "split_shadows": 25.0, "split_mid": 50.0, "split_highlights": 75.0, "master": [{"x": 0.0, "y": 0.05}, {"x": 0.5, "y": 0.55}, {"x": 1.0, "y": 0.95}], "red": [], "green": [], "blue": [], "refine_saturation": 100.0}, + "color": {"vibrance": 20.0, "saturation": -5.0}, + "mixer": {"red": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "orange": {"hue": 0.0, "sat": 0.0, "lum": 15.0}, "yellow": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "green": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "aqua": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blue": {"hue": 0.0, "sat": -30.0, "lum": 0.0}, "purple": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "magenta": {"hue": 0.0, "sat": 0.0, "lum": 0.0}}, + "point_colors": [], + "bw_mix": {"red": 0.0, "orange": 0.0, "yellow": 0.0, "green": 0.0, "aqua": 0.0, "blue": 0.0, "purple": 0.0, "magenta": 0.0}, + "grading": {"shadows": {"hue": 220.0, "sat": 20.0, "lum": -5.0}, "midtones": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "highlights": {"hue": 40.0, "sat": 15.0, "lum": 5.0}, "global": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blending": 50.0, "balance": 0.0}, + "effects": {"texture": 20.0, "clarity": 25.0, "dehaze": 15.0}, + "vignette": {"amount": -25.0, "midpoint": 50.0, "roundness": 0.0, "feather": 50.0, "highlights": 0.0, "style": "highlightPriority"}, + "grain": {"amount": 20.0, "size": 25.0, "roughness": 50.0, "seed": 0}, + "detail": {"sharpen_amount": 40.0, "sharpen_radius": 1.0, "sharpen_detail": 25.0, "sharpen_masking": 0.0, "nr_luminance": 20.0, "nr_detail": 50.0, "nr_contrast": 0.0, "nr_color": 25.0, "nr_color_detail": 50.0, "nr_color_smoothness": 50.0}, + "optics": {"remove_ca": false, "lens_profile": false, "profile_distortion": 100.0, "profile_vignetting": 100.0, "defringe_purple_amount": 0.0, "defringe_purple_hue_lo": 30.0, "defringe_purple_hue_hi": 70.0, "defringe_green_amount": 0.0, "defringe_green_hue_lo": 40.0, "defringe_green_hue_hi": 60.0, "distortion": 0.0, "vignetting": 0.0, "vignetting_midpoint": 50.0, "ca_red": 0.0, "ca_blue": 0.0}, + "geometry": {"upright": "off", "guides": [], "upright_transform": null, "vertical": 0.0, "horizontal": 0.0, "rotate": 0.0, "aspect": 0.0, "scale": 100.0, "offset_x": 0.0, "offset_y": 0.0, "constrain_crop": false}, + "crop": {"geometry": {"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0}, "aspect": null, "flip_h": false, "flip_v": false}, + "orientation": "Normal", + "masks": [{"id": 1, "name": "Mask 1", "visible": true, "invert": false, "components": [{"op": "add", "invert": false, "shape": {"kind": "radial", "center": {"x": 0.4, "y": 0.5}, "rx": 0.3, "ry": 0.2, "angle": 15.0, "feather": 60.0, "invert": false}}], "adjust": {"temp": 10.0, "tint": 0.0, "exposure": 0.5, "contrast": 0.0, "highlights": 0.0, "shadows": 0.0, "whites": 0.0, "blacks": 0.0, "texture": 0.0, "clarity": 30.0, "dehaze": 0.0, "hue": 0.0, "saturation": 0.0, "sharpness": 0.0, "noise": 0.0, "moire": 0.0, "defringe": 0.0, "color_hue": 0.0, "color_sat": 0.0, "amount": 100.0}}], + "spots": [], + "red_eye": [], + "lens_blur": {"enabled": false, "amount": 0.0, "focal_distance": 0.0, "focal_range": 0.0}, + "enhance": {"denoise": 0.0, "raw_details": false, "super_resolution": false}, + "calibration": {"shadows_tint": 0.0, "red_hue": 0.0, "red_sat": 0.0, "green_hue": 0.0, "green_sat": 0.0, "blue_hue": 0.0, "blue_sat": 0.0}, + "disabled_sections": [] +} +"#; + +fn legacy() -> Value { + serde_json::from_str(LEGACY).unwrap() +} + +#[test] +fn settings_saved_before_process_versions_load_as_v1() { + let v = legacy(); + assert!(v.get("process").is_none(), "the fixture predates the field"); + let s = DevelopSettings::from_json(&v).unwrap(); + // V1 as a number, not `LATEST`: once a newer process is the latest, this is what keeps every + // existing catalog, sidecar and preset on the rendering it was made with + assert_eq!(s.process, ProcessVersion(1)); + assert_eq!(s.process, ProcessVersion::legacy()); + assert_eq!((s.light.exposure, s.light.highlights, s.masks.len(), s.detail.sharpen_amount), (0.4, -60.0, 1, 40.0)); + // the smallest legacy file too, and settings nested in other records (history, versions) + assert_eq!(DevelopSettings::from_json(&json!({})).unwrap().process, ProcessVersion(1)); + let nested: Vec = serde_json::from_value(json!([{}, {"light": {"exposure": 1.0}}])).unwrap(); + assert!(nested.iter().all(|s| s.process == ProcessVersion(1))); + // saved again, they gain no field: files and preview-cache keys (`hash64`) stay as they were + assert!(s.to_json().get("process").is_none()); +} + +#[test] +fn new_settings_get_the_latest_process() { + assert_eq!(DevelopSettings::default().process, ProcessVersion::LATEST); + assert_eq!(DevelopSettings::for_raw(5200.0, 4.0).process, ProcessVersion::LATEST); + assert_eq!(ProcessVersion::LATEST, Process::LATEST.version()); + assert_eq!(ProcessVersion::V1, ProcessVersion(1)); + // the processes are listed oldest first, the latest last, each with its own number + let versions: Vec = Process::ALL.iter().map(|p| p.version().0).collect(); + assert!(versions.windows(2).all(|w| w[0] < w[1]), "{versions:?}"); + assert_eq!(Process::ALL.last(), Some(&Process::LATEST)); + assert!(Process::ALL.iter().all(|p| p.version().process() == *p && p.version().is_known())); +} + +#[test] +fn v1_is_left_out_when_saving_and_other_processes_are_kept() { + let mut s = DevelopSettings::from_json(&legacy()).unwrap(); + assert!(s.to_json().get("process").is_none()); + let before = s.hash64(); + s.process = ProcessVersion(2); + let v = s.to_json(); + assert_eq!(v["process"], json!(2)); + assert_eq!(DevelopSettings::from_json(&v).unwrap().process, ProcessVersion(2)); + assert_ne!(s.hash64(), before, "another process is another preview"); +} + +#[test] +fn unknown_process_numbers_load_and_render_with_the_newest_known() { + // written by a newer LightCraft: kept as it is, rendered with the latest process this build has + let s = DevelopSettings::from_json(&json!({"process": 7, "light": {"exposure": 0.5}})).unwrap(); + assert_eq!(s.process, ProcessVersion(7)); + assert_eq!(s.process.process(), Process::LATEST); + assert!(!s.process.is_known()); + assert!(!s.process.is_outdated(), "never downgraded by Update to Current Process"); + assert_eq!(s.to_json()["process"], json!(7)); + assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(7)); + // below V1 (no LightCraft writes it): rendered as V1, and updatable + let zero = DevelopSettings::from_json(&json!({"process": 0})).unwrap().process; + assert_eq!((zero.process(), zero.is_known(), zero.is_outdated()), (Process::V1, false, true)); + let max = DevelopSettings::from_json(&json!({"process": u32::MAX})).unwrap().process; + assert_eq!(max.process(), Process::LATEST); + // a whole number written as a float is that number + assert_eq!(DevelopSettings::from_json(&json!({"process": 2.0})).unwrap().process, ProcessVersion(2)); +} + +#[test] +fn a_damaged_process_value_reads_as_v1_and_keeps_the_edits() { + let too_big = u64::from(u32::MAX) + 1; + for bad in [ + json!(-1), + json!(1.5), + json!(-0.5), + json!(1e300), + json!(too_big), + json!(u64::MAX), + json!("2"), + json!(null), + json!(true), + json!([2]), + json!({"v": 2}), + ] { + let mut v = legacy(); + v["process"] = bad.clone(); + let s = DevelopSettings::from_json(&v).unwrap_or_else(|e| panic!("{bad}: {e}")); + assert_eq!(s.process, ProcessVersion::V1, "{bad}"); + assert_eq!((s.light.exposure, s.masks.len(), s.curve.master.len()), (0.4, 1, 3), "{bad}: the rest of the settings load"); + // nested in other records too (history, versions, the catalog) + let nested: Vec = serde_json::from_value(json!([v])).unwrap(); + assert_eq!(nested[0].process, ProcessVersion::V1); + } +} + +#[test] +fn the_process_is_not_an_edit_nor_part_of_copied_settings() { + let mut s = DevelopSettings { process: ProcessVersion(LATEST_PLUS_ONE), ..Default::default() }; + assert!(s.is_unedited(), "a photo on another process is not edited for that"); + s.light.exposure = 1.0; + assert!(!s.is_unedited()); + // resetting a section or a slider leaves the process alone + s.reset_section(Section::Light); + assert_eq!(s.process, ProcessVersion(LATEST_PLUS_ONE)); + // copy / paste / sync / presets: no group carries the process, so the target keeps its own + assert!(SettingsGroup::ALL.iter().all(|g| !g.keys().contains(&"process"))); + s.light.exposure = 1.0; + let copied = extract_groups(&s, &SettingsGroup::ALL); + assert!(copied.get("process").is_none()); + let target = DevelopSettings::from_json(&legacy()).unwrap(); + let pasted = apply_partial(&target, &copied, 1.0); + assert_eq!((pasted.process, pasted.light.exposure), (ProcessVersion(1), 1.0)); + // (at another amount, without Grain: its integer seed doesn't survive scaling, a separate issue) + let groups: Vec = SettingsGroup::ALL.into_iter().filter(|g| *g != SettingsGroup::Grain).collect(); + let preset = Preset::from_settings("p", "P", "User", &s, &groups); + let half = preset.apply(&target, 0.5); + assert_eq!((half.process, half.light.exposure), (ProcessVersion(1), 0.7)); + // a partial that names a process explicitly (Apply Settings JSON) sets it + assert_eq!(target.merged(&json!({"process": LATEST_PLUS_ONE})).unwrap().process, ProcessVersion(LATEST_PLUS_ONE)); +} + +/// A process other than the latest: a newer LightCraft's number (no older one than V1 exists). +const LATEST_PLUS_ONE: u32 = ProcessVersion::LATEST.0 + 1; diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index e0aaaf95..fb145410 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -1,7 +1,7 @@ //! Develop commands. All operate on the active photo unless `ids` are given (sync). use lightcraft_catalog::{Op, PhotoId, Version}; -use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls}; +use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls}; use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop}; use serde_json::{Value, json}; @@ -74,6 +74,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32) d.crop.geometry = crop_fit_angle(w, h, angle, a); } +/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`]. +fn outdated(s: &Session, id: PhotoId) -> bool { + s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated()) +} + +fn already_current() -> String { + format!("already on the current process (version {})", ProcessVersion::LATEST.0) +} + +/// Update to Current Process applies to the selection (the selected photos, else the active +/// photo): one of them is on an older rendering process. Calls that name their photos are +/// checked by the command instead (`explicit_targets`). +fn has_outdated_process(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let sel = &s.selection; + let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() }; + if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) } +} + +/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it +/// names none (the selection applies). +fn named_photos(s: &Session, p: &Value, c: &str) -> Result>> { + if !super::names_photos(p) { + return Ok(None); + } + let given = match p.get("ids").filter(|v| !v.is_null()) { + Some(Value::Array(a)) => a.clone(), + Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")), + None => p.get("id").cloned().into_iter().collect(), + }; + if given.is_empty() { + return Err(bad(c, "`ids` names no photos")); + } + let ids = given + .iter() + .map(|v| match v.as_u64().map(PhotoId) { + Some(id) if s.catalog.photo(id).is_some() => Ok(id), + _ => Err(bad(c, format!("no such photo {v}"))), + }) + .collect::>>()?; + Ok(Some(ids)) +} + /// The user preset named by `p.id` (built-ins can't be changed). fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> { let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?; @@ -203,12 +246,44 @@ pub fn specs() -> Vec { } } }; - s.develop_op(id, fresh, "Reset") + // a fresh start renders with the current process (a section or slider reset doesn't change it) + s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset") }) .collect(); s.commit("Reset", Op::Batch { ops })?; ok() }), + CommandSpec { + explicit_targets: true, + ..cmd!( + "develop.updateProcess", + "Update to Current Process", + ["Photo"], + None, + "{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}", + has_outdated_process, + |s, p| { + const C: &str = "develop.updateProcess"; + const LABEL: &str = "Update to Current Process"; + let targets = match named_photos(s, p, C)? { + Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())), + Some(ids) => ids, + None => s.targets(p), + }; + let ops: Vec = targets + .into_iter() + .filter_map(|id| s.develop_of(id).map(|d| (id, d))) + .filter(|(_, d)| d.process.is_outdated()) + .filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL)) + .collect(); + let n = ops.len(); + if n > 0 { + s.commit(LABEL, Op::Batch { ops })?; + } + Ok(json!({"changed": n, "process": ProcessVersion::LATEST})) + } + ) + }, cmd!( "develop.resetSection", "Reset Section", diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ef838fe0..96c24a89 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -54,6 +54,10 @@ pub struct CommandSpec { pub run: Run, /// Record in the journal (false for queries). pub journal: bool, + /// `enabled` judges the selection (menus, shortcuts); a call that names its photos (`ids` / + /// `id`, see [`names_photos`]) skips it, and the command validates those photos itself. + /// Off unless a command opts in (`CommandSpec { explicit_targets: true, ..cmd!(…) }`). + pub explicit_targets: bool, } #[derive(Clone, Debug, Serialize)] @@ -107,10 +111,10 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { macro_rules! cmd { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true } + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, explicit_targets: false } }; (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false } + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, explicit_targets: false } }; } pub(crate) use cmd; @@ -158,6 +162,11 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> { // ---------- param helpers +/// The call names its target photos (`ids` or `id`, not null) instead of using the selection. +pub(crate) fn names_photos(p: &Value) -> bool { + ["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())) +} + pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 824039e4..6ac26095 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -149,9 +149,18 @@ pub fn specs() -> Vec { } Ok(v) }), - cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| { + cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?} → the photo's develop settings; `process` is the rendering process they are interpreted by (see develop.updateProcess)", always, |s, p| { let id = photo_arg(s, p, "develop.get")?; - Ok(s.develop_of(id).map(|d| d.to_json()).unwrap_or(Value::Null)) + Ok(s.develop_of(id) + .map(|d| { + let mut v = d.to_json(); + // saved settings leave V1 out (`ProcessVersion::is_legacy`); here it is always stated + if let Some(o) = v.as_object_mut() { + o.insert("process".into(), json!(d.process)); + } + v + }) + .unwrap_or(Value::Null)) }), cmd!(query "develop.controls", "List Develop Controls", [], None, "{section?} — every slider with range, default and current value", always, |s, p| { let d = s.active().and_then(|id| s.develop_of(id)).unwrap_or_default(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6c5f642..8d3e9af4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -352,7 +352,10 @@ impl Session { /// Run a command by id. THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - (spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?; + // a command that validates the photos a call names isn't held back by the selection + if !(spec.explicit_targets && cmd::names_photos(params)) { + (spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?; + } let empty = Value::Object(Default::default()); let params = if params.is_null() { &empty } else { params }; self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)) @@ -626,7 +629,8 @@ impl Session { /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the /// other selected photos: only the settings that changed; never spot removal or red eye (they - /// belong to one photo's pixels), nor history / snapshot restores. + /// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor + /// history / snapshot restores. fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec { if !self.auto_sync || self.active() != Some(id) @@ -639,7 +643,7 @@ impl Session { let Some(old) = self.develop_of(id) else { return Vec::new() }; let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() }; if let Some(o) = delta.as_object_mut() { - for k in ["spots", "red_eye", "version"] { + for k in ["spots", "red_eye", "version", "process"] { o.remove(k); } if o.is_empty() { @@ -871,6 +875,8 @@ mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_process; +#[cfg(test)] mod tests_segment; #[cfg(test)] mod tests_settings_files; diff --git a/crates/engine/src/tests_process.rs b/crates/engine/src/tests_process.rs new file mode 100644 index 00000000..efa46601 --- /dev/null +++ b/crates/engine/src/tests_process.rs @@ -0,0 +1,262 @@ +//! Process versions through the commands (`docs/process-versions.md`): new photos and Reset get +//! the latest process, Update to Current Process is one undo step, copies inherit, copy / paste / +//! sync / presets / Auto Sync keep each photo's own, and numbers this build doesn't know survive a +//! save and a sidecar round trip. + +use std::sync::Arc; + +use lightcraft_catalog::{Photo, PhotoId, Source}; +use lightcraft_develop::{DevelopSettings, ProcessVersion}; +use serde_json::json; + +use crate::{EngineError, Session}; + +/// No process is older than V1 yet: 0 (which no LightCraft writes) stands in for one. +const OLDER: ProcessVersion = ProcessVersion(0); +/// A number from a newer LightCraft. +const NEWER: ProcessVersion = ProcessVersion(ProcessVersion::LATEST.0 + 6); + +fn process(s: &Session, id: PhotoId) -> ProcessVersion { + s.develop_of(id).unwrap().process +} + +/// Put photo `id` on process `p` (with exposure `ev`) without going through a command. +fn put(s: &mut Session, id: PhotoId, p: ProcessVersion, ev: f64) { + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.process = p; + d.light.exposure = ev; + s.set_develop(id, d, "test").unwrap(); +} + +fn select(s: &mut Session, ids: &[PhotoId]) { + s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn update_info(s: &Session) -> crate::CommandInfo { + s.commands().into_iter().find(|c| c.id == "develop.updateProcess").unwrap() +} + +#[test] +fn new_photos_and_reset_get_the_latest_process() { + let mut s = Session::with_demo(); + assert!(s.catalog.photos().all(|p| p.develop.process == ProcessVersion::LATEST)); + let ids: Vec = s.visible_cloned().into_iter().take(2).collect(); + put(&mut s, ids[0], OLDER, 0.7); + put(&mut s, ids[1], NEWER, -0.3); + // a section or a slider reset leaves the process alone + select(&mut s, &ids[1..]); + s.execute("develop.resetSection", &json!({"section": "light"})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.2})).unwrap(); + s.execute("develop.resetControl", &json!({"control": "light.exposure"})).unwrap(); + let d = s.develop_of(ids[1]).unwrap(); + assert_eq!((d.process, d.light.exposure), (NEWER, 0.0)); + // a full reset is a fresh start on the latest process, also from a newer one + select(&mut s, &ids); + s.execute("develop.reset", &json!({})).unwrap(); + for id in &ids { + assert_eq!(process(&s, *id), ProcessVersion::LATEST); + assert!(!s.catalog.photo(*id).unwrap().is_edited()); + } + // a photo whose default preset's look was recorded on an older process + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(process(&s, ids[0]), OLDER); + let mut p = (**s.catalog.photo(ids[0]).unwrap()).clone(); + let mut look = p.camera_defaults(); + look.light.contrast = 20.0; + look.process = OLDER; + p.import_look = Some(Arc::new(look)); + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: p.id }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.execute("develop.reset", &json!({"ids": [ids[0].0]})).unwrap(); + let d = s.develop_of(ids[0]).unwrap(); + assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 20.0)); +} + +#[test] +fn update_to_current_process_is_one_undo_step_and_only_when_needed() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().into_iter().take(3).collect(); + select(&mut s, &ids); + // everything is current: disabled, with the reason + let info = update_info(&s); + assert!(!info.enabled && info.disabled_reason.as_deref().is_some_and(|r| r.contains("already on the current process")), "{info:?}"); + assert!(matches!(s.execute("develop.updateProcess", &json!({})), Err(EngineError::Disabled(..)))); + // in the Photo menu + assert_eq!(info.menu, vec!["Photo"]); + + put(&mut s, ids[0], OLDER, 0.5); + put(&mut s, ids[1], OLDER, -0.5); + put(&mut s, ids[2], NEWER, 0.25); + select(&mut s, &ids); + assert!(update_info(&s).enabled); + let undo = s.undo.len(); + let r = s.execute("develop.updateProcess", &json!({})).unwrap(); + assert_eq!(r, json!({"changed": 2, "process": ProcessVersion::LATEST.0})); + assert_eq!(s.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.undo.last().unwrap().label, "Update to Current Process"); + assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]); + assert_eq!(process(&s, ids[2]), NEWER, "a newer process is never downgraded"); + assert_eq!(s.develop_of(ids[0]).unwrap().light.exposure, 0.5, "the sliders keep their values"); + assert_eq!(s.catalog.photo(ids[1]).unwrap().history.last().unwrap().label, "Update to Current Process"); + assert!(!update_info(&s).enabled, "nothing left to update"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [OLDER; 2]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]); + // a photo on a newer process alone: nothing to update + select(&mut s, &ids[2..]); + assert!(!update_info(&s).enabled); + // the active photo when nothing else is selected + put(&mut s, ids[0], OLDER, 0.5); + s.selection = crate::Selection { ids: Vec::new(), active: Some(ids[0]) }; + assert!(update_info(&s).enabled); + assert_eq!(s.execute("develop.updateProcess", &json!({})).unwrap()["changed"], 1); +} + +#[test] +fn virtual_copies_inherit_the_process() { + let mut s = Session::with_demo(); + let src = s.visible_cloned()[0]; + put(&mut s, src, NEWER, 0.4); + select(&mut s, &[src]); + let r = s.execute("photo.virtualCopy", &json!({})).unwrap(); + let copy = PhotoId(r["ids"][0].as_u64().unwrap()); + assert_eq!(process(&s, copy), NEWER); + assert!(s.develop_of(copy).unwrap() == s.develop_of(src).unwrap()); +} + +#[test] +fn copy_paste_sync_presets_and_auto_sync_keep_each_photos_process() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().into_iter().take(3).collect(); + let (src, a, b) = (ids[0], ids[1], ids[2]); + put(&mut s, src, NEWER, 1.0); + put(&mut s, b, OLDER, 0.0); + select(&mut s, &[src]); + s.execute("develop.copy", &json!({"groups": lightcraft_develop::SettingsGroup::ALL})).unwrap(); + select(&mut s, &[a, b]); + s.execute("develop.paste", &json!({})).unwrap(); + assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]); + assert_eq!(s.develop_of(b).unwrap().light.exposure, 1.0, "the values are pasted"); + // sync from the active photo + put(&mut s, src, NEWER, -1.0); + s.selection = crate::Selection { ids: vec![src, a, b], active: Some(src) }; + s.execute("develop.sync", &json!({})).unwrap(); + assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]); + assert_eq!(s.develop_of(a).unwrap().light.exposure, -1.0); + // a preset made from the photo + select(&mut s, &[src]); + // (not Grain: its integer seed doesn't survive scaling, a separate issue) + let groups: Vec<_> = lightcraft_develop::SettingsGroup::ALL.into_iter().filter(|g| *g != lightcraft_develop::SettingsGroup::Grain).collect(); + let pid = s.execute("preset.create", &json!({"name": "Everything", "groups": groups})).unwrap()["id"].clone(); + put(&mut s, b, OLDER, 0.0); + select(&mut s, &[b]); + s.execute("preset.apply", &json!({"id": pid, "amount": 50})).unwrap(); + assert_eq!(process(&s, b), OLDER); + assert_eq!(s.develop_of(b).unwrap().light.exposure, -0.5, "the preset applied (at 50 %)"); + // Auto Sync carries an edit of the active photo, not its process + s.selection = crate::Selection { ids: vec![src, a], active: Some(src) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + s.execute("develop.merge", &json!({"settings": {"process": OLDER.0, "light": {"contrast": 15}}})).unwrap(); + assert_eq!(process(&s, src), OLDER, "Apply Settings JSON sets what it names"); + let d = s.develop_of(a).unwrap(); + assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 15.0)); +} + +#[test] +fn process_numbers_survive_a_reopen_and_develop_get_reports_them() { + let dir = std::env::temp_dir().join(format!("lc-process-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.open_library(&dir, true).unwrap(); + let ids: Vec = s.visible_cloned().into_iter().take(2).collect(); + select(&mut s, &ids[..1]); + s.execute("develop.merge", &json!({"settings": {"process": NEWER.0, "light": {"exposure": 0.5}}})).unwrap(); + assert_eq!(s.execute("develop.get", &json!({"id": ids[0].0})).unwrap()["process"], json!(NEWER.0)); + assert_eq!(s.execute("develop.get", &json!({"id": ids[1].0})).unwrap()["process"], json!(ProcessVersion::LATEST.0)); + // it renders (with the newest process this build has) + assert!(s.render_now(ids[0], 48, 48).is_ok()); + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s = Session::new(); + s.open_library(&dir, false).unwrap(); + assert_eq!(s.catalog.to_snapshot(), expect); + assert_eq!(process(&s, ids[0]), NEWER); + assert_eq!(process(&s, ids[1]), ProcessVersion::LATEST); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sidecars_keep_the_process_and_interchange_edits_get_the_latest() { + use crate::sidecar::{DevelopPatch, merge_into, parse_sidecar, sidecar_packet}; + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0]; + // our own settings round-trip with their process; V1 settings say nothing (as before process + // versions, so sidecars written then read as V1) + put(&mut s, id, NEWER, 0.3); + let p = s.catalog.photo(id).unwrap().clone(); + let back = parse_sidecar(&sidecar_packet(&p, &s.catalog), false).unwrap(); + let Some(DevelopPatch::Full(d)) = &back.develop else { panic!("no lc:settings") }; + assert_eq!((d.process, d.light.exposure), (NEWER, 0.3)); + put(&mut s, id, ProcessVersion::V1, 0.3); + let packet = sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog); + assert!(!packet.contains("process"), "{packet}"); + let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, false).unwrap().develop else { panic!("no lc:settings") }; + assert_eq!(d.process, ProcessVersion::V1); + // a damaged process value reads as V1 and costs nothing else: the sidecar's edits still come back + for bad in ["-1", "1.5", "\"two\"", "null", "4294967296"] { + let json = format!(r#"{{"process": {bad}, "light": {{"exposure": 0.5}}}}"#); + let packet = lightcraft_meta::write_xmp(&lightcraft_meta::Metadata::default(), Some(&json)); + let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, false).unwrap().develop else { panic!("{bad}: the edit was dropped") }; + assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5), "{bad}"); + } + // `crs:` edits (other raw developers, Lightroom): their ProcessVersion numbers another + // renderer and is ignored; mapped like a preset, they leave the photo's process as it is, + // which for a newly imported photo is the latest + let xmp = r#" + "#; + let sc = parse_sidecar(xmp, true).unwrap(); + let Some(DevelopPatch::Partial(partial)) = &sc.develop else { panic!("no crs: edit") }; + assert!(partial.get("process").is_none(), "{partial}"); + let mut fresh = Photo::new(PhotoId(999), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00"); + assert!(merge_into(&mut fresh, &sc, "2026-10-09T00:00:00")); + assert_eq!((fresh.develop.process, fresh.develop.light.exposure), (ProcessVersion::LATEST, 1.1)); + let mut older = fresh.clone(); + older.develop = Arc::new(DevelopSettings { process: OLDER, ..Default::default() }); + assert!(merge_into(&mut older, &sc, "2026-10-09T00:00:00")); + assert_eq!(older.develop.process, OLDER); +} + +#[test] +fn update_to_current_process_checks_named_photos_not_the_selection() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().into_iter().take(3).collect(); + let (old, current, other) = (ids[0], ids[1], ids[2]); + put(&mut s, old, OLDER, 0.5); + // a different, current photo is selected: the menu item is off, but naming the photo works + select(&mut s, &[current]); + assert!(!update_info(&s).enabled); + let undo = s.undo.len(); + assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0]})).unwrap()["changed"], 1); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(process(&s, old), ProcessVersion::LATEST); + assert_eq!(s.develop_of(old).unwrap().light.exposure, 0.5); + // with nothing selected at all, by `ids` or `id` + put(&mut s, old, OLDER, 0.5); + put(&mut s, other, OLDER, 0.0); + s.selection = crate::Selection { ids: Vec::new(), active: None }; + assert!(!update_info(&s).enabled, "no selection"); + assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0, current.0]})).unwrap()["changed"], 1); + assert_eq!(s.execute("develop.updateProcess", &json!({"id": other.0})).unwrap()["changed"], 1); + assert_eq!([process(&s, old), process(&s, other)], [ProcessVersion::LATEST; 2]); + // named photos are validated: all current, unknown, malformed or none + let err = s.execute("develop.updateProcess", &json!({"ids": [current.0]})).unwrap_err(); + assert!(matches!(&err, EngineError::Disabled(_, why) if why.contains("already on the current process")), "{err}"); + for bad in [json!({"ids": [99_999]}), json!({"ids": ["x"]}), json!({"ids": "x"}), json!({"ids": []}), json!({"id": -1})] { + assert!(matches!(s.execute("develop.updateProcess", &bad), Err(EngineError::BadParams { .. })), "{bad}"); + } + // `ids: null` names nothing: the selection decides (and there is none) + assert!(matches!(s.execute("develop.updateProcess", &json!({"ids": null})), Err(EngineError::Disabled(..)))); +} diff --git a/crates/pipeline/src/auto.rs b/crates/pipeline/src/auto.rs index 3455fff2..cb7a3f47 100644 --- a/crates/pipeline/src/auto.rs +++ b/crates/pipeline/src/auto.rs @@ -28,10 +28,11 @@ fn percentile(sorted: &[f32], q: f32) -> f32 { sorted[((sorted.len() - 1) as f32 * q.clamp(0.0, 1.0)) as usize] } -/// Compute auto tone values for `src` under the current white balance (ignores current tone values). +/// Compute auto tone values for `src` under the current white balance and rendering process +/// (ignores current tone values). pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTone { let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box); - let mut base = DevelopSettings { wb: s.wb, ..DevelopSettings::default() }; + let mut base = DevelopSettings { wb: s.wb, process: s.process, ..DevelopSettings::default() }; base.light.exposure = 0.0; crate::local::scene_linear_pre(&mut img, info, &base); let mut ev: Vec = img.data.iter().map(|c| (luminance_2020(*c).max(1e-6) / 0.18).log2()).collect(); @@ -65,7 +66,7 @@ pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTo pub fn auto_bw_mix(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> [f64; 8] { use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box); - let base = DevelopSettings { wb: s.wb, light: s.light, ..DevelopSettings::default() }; + let base = DevelopSettings { wb: s.wb, light: s.light, process: s.process, ..DevelopSettings::default() }; crate::local::scene_linear_pre(&mut img, info, &base); let gain = 2f32.powf(base.light.exposure as f32); let (mut mass, mut sum_l) = ([0f64; 8], [0f64; 8]); diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 6b422ee9..6971527f 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -3,7 +3,7 @@ use lightcraft_color::luminance_2020; use lightcraft_color::spline::{Lut1, MonotoneCurve}; use lightcraft_color::transfer::linear_to_srgb; -use lightcraft_develop::{DevelopSettings, LocalAdjustments, ToneCurve, VignetteStyle}; +use lightcraft_develop::{DevelopSettings, LocalAdjustments, Process, ToneCurve, VignetteStyle}; use lightcraft_geom::Point; use lightcraft_raster::Rgba8; @@ -223,13 +223,7 @@ impl FinishParams { FinishParams { calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None }, shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 }, - tone: if let Some(curve) = info.camera_tone.as_ref().filter(|_| info.raw) { - ToneMap::camera(curve, s.light.contrast, s.light.whites, s.light.blacks) - } else if info.raw { - ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks) - } else { - ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks) - }, + tone: base_tone(s, info), lut: crate::lut::get(&s.profile.id).map(|l| (l, (s.profile.amount / 100.0).clamp(0.0, 2.0) as f32)), ops: ColorOps::new(s), curves: curve_luts(&s.curve), @@ -262,6 +256,21 @@ impl FinishParams { } } +/// The base tone map (scene → display luminance, with Contrast, Whites and Blacks) of `s`'s +/// rendering process. This is where a new tone model branches: a retuned default tone curve (issue +/// #146) is a new [`Process`] with its own arm here, and V1's arm stays as it is, so photos edited +/// under V1 keep their look (`docs/process-versions.md`). CPU and GPU both take it from here. +pub fn base_tone(s: &DevelopSettings, info: &SourceInfo) -> ToneMap { + let l = &s.light; + match s.process.process() { + Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw) { + Some(curve) => ToneMap::camera(curve, l.contrast, l.whites, l.blacks), + None if info.raw => ToneMap::new(l.contrast, l.whites, l.blacks), + None => ToneMap::display(l.contrast, l.whites, l.blacks), + }, + } +} + pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, proof: Option) -> Rgba8 { let (w, h) = (p.img.width, p.img.height); let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 84f5bd98..fc5a4413 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -574,9 +574,9 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option } /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" -/// (default look, keeping the crop so framing matches). +/// (default look, keeping the crop so framing matches, under the photo's own rendering process). pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { - let mut b = DevelopSettings { crop: s.crop, orientation: s.orientation, ..DevelopSettings::default() }; + let mut b = DevelopSettings { crop: s.crop, orientation: s.orientation, process: s.process, ..DevelopSettings::default() }; b.wb = lightcraft_develop::WhiteBalance { mode: lightcraft_develop::WbMode::AsShot, ..b.wb }; b } diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index f572b21d..603883b9 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -435,3 +435,49 @@ fn custom_white_balance_redevelops_in_camera_space() { s.wb.mode = WbMode::AsShot; assert!(wb_matrix_for(&info, &s).is_none()); } + +/// Every stored process number renders with a process this build knows: settings saved before +/// process versions existed exactly as V1, and a number from a newer LightCraft exactly as the +/// latest process here (`docs/process-versions.md`). Raw (base tone curve), camera-tone and +/// rendered sources, plain and edited, 8-bit and 16-bit. +#[test] +fn stored_process_numbers_render_with_a_known_process() { + use lightcraft_develop::{Process, ProcessVersion}; + let src = scene(); + let curve = crate::tone::CameraTone::new(std::array::from_fn(|i| { + let x = 0.004 * 1.18f32.powi(i as i32); + [x, 1.0 - (-2.0 * x).exp()] + })) + .unwrap(); + let infos = [ + SourceInfo::default(), + SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 4.0, ..Default::default() }, + SourceInfo { raw: true, relative_wb: true, camera_tone: Some(curve), ..Default::default() }, + ]; + let mut edited = DevelopSettings::for_raw(5200.0, 4.0); + for (id, v) in [("light.exposure", 0.4), ("light.contrast", 30.0), ("light.highlights", -60.0), ("light.whites", 15.0), ("light.blacks", -20.0)] { + controls::set(&mut edited, id, v); + } + let legacy = |s: &DevelopSettings| { + let mut v = s.to_json(); + v.as_object_mut().unwrap().remove("process"); + DevelopSettings::from_json(&v).unwrap() + }; + let with = |s: &DevelopSettings, p: ProcessVersion| DevelopSettings { process: p, ..s.clone() }; + let deep = RenderRequest { depth: crate::OutputDepth::U16, ..RenderRequest::fit(96, 96) }; + for info in &infos { + for s in [DevelopSettings::default(), edited.clone()] { + for req in [RenderRequest::fit(96, 96), deep] { + let out = |s: &DevelopSettings| { + let r = render(&src, info, s, &req); + (r.image.data, r.deep) + }; + let v1 = out(&with(&s, ProcessVersion::V1)); + assert_eq!(legacy(&s).process, ProcessVersion::V1); + assert!(out(&legacy(&s)) == v1, "saved before process versions: rendered as V1"); + let latest = out(&with(&s, Process::LATEST.version())); + assert!(out(&with(&s, ProcessVersion(Process::LATEST.version().0 + 7))) == latest, "newer than this build: as the latest"); + } + } + } +} diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 7d964856..29f13fdc 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -184,6 +184,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "develop.auto", "develop.treatment", "develop.reset", + "develop.updateProcess", "dialog.createPreset", "---", "photo.saveMetadataToFile", diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index d3d1940e..13c3f3a1 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -38,7 +38,9 @@ WITHOUT ROWID or virtual table produces an explicit error, handled as a warning Develop settings reuse the existing XMP/preset mapper. Supported sliders, curves and supported mask structures remain editable, but this is approximate rendering: camera profiles, Adobe AI models, some masking/retouch fields and process-version algorithms are not reproduced. Unmapped -fields are reported. When a recovery archive is saved, it retains source settings/history/snapshots +fields are reported. Lightroom's `ProcessVersion` is not mapped: newly imported photos are on +LightCraft's latest rendering process, and photos already in the library keep theirs +([process-versions.md](process-versions.md)). 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. Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog and XMP mappings; diff --git a/docs/mcp.md b/docs/mcp.md index 7e815406..a541e9c6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -83,7 +83,7 @@ During development you can also point the client at `cargo run --release -p ligh | `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) | | `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were | | `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | -| `get_develop {id?}` | Full develop-settings JSON | +| `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 | | `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` | diff --git a/docs/parity.md b/docs/parity.md index 4b992c99..3a696555 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 18 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 394 | 38 | 84 | 36 | 193/200 (97%) | 142/152 (93%) | +| Lightroom Classic extras | 33 | 11 | 39 | 9 | — | 23/25 (92%) | +| **Total** | 395 | 38 | 84 | 36 | 193/200 (97%) | 143/153 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **80.0%** of 516 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **80.1%** of 517 in-scope rows — P0 98.2% of 200 · P1 95.8% of 153 · P2 42.9% of 163. ## Top gaps @@ -71,7 +71,8 @@ Take the first one nobody is working on. 3. **LR-IMP-CAMERA-COVERAGE** (P0): per-model verification; grow the CC0 corpus and fix per-model bugs (like the CR2 colour-filter layout, fixed in #85 by reading the file's own tag). 4. **LR-BEHAV-RENDER-FIDELITY** (P1): a side-by-side fidelity suite against Lightroom renders (kept local in `plan/`), - then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it. + then tune tone, highlights, texture/clarity/dehaze, NR and sharpening against it. A retune that changes how existing + settings render ships as a new process version, so existing edits keep their look (`docs/process-versions.md`). 4b. **LR-VIEW-ZOOM** (P0): finish the sharp zoomed views of issue #323: windows for compare, overlays and soft proofing (Before/After has them); check sharpness and the wide-stage margin on real raw photos (only synthetic scenes so far). 5. **LR-EDIT-OPTICS-PROFILE** (P1): a lens-profile database of our own (corrections embedded in DNG files and the @@ -517,7 +518,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); no tiling beyond the buffer limit (CPU) | -| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric | +| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | | LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`sidebar_section_header`), `crates/ui-egui/src/state.rs` (`collapsed_sidebar`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date or Keywords header (or drive `ui.clickWidget` `sidebarSection:`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`collapsedSidebar` in the UI state). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target | @@ -768,6 +769,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-CALIB | Calibration panel | P1 | ✅ | `ctl:calibration.*` | | | LRC-DEV-TAT | Targeted adjustment tools | P1 | ✅ | `cmd:develop.targeted` (`target`: curve / hue / sat / lum) | | | LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets | +| LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | each photo records the rendering process its settings are interpreted by: settings saved before this existed load as V1, new photos and Reset get the latest, virtual copies inherit, copy / paste / sync / presets keep each photo's own; Photo ▸ Update to Current Process (one undo step, enabled while a selected photo is on an older process); a number from a newer LightCraft is kept and renders with the newest known process. Only V1 exists so far: this is what lets a retuned default tone (#146) or stronger Light sliders (#196) ship without changing existing edits | | LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | | | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | diff --git a/docs/process-versions.md b/docs/process-versions.md new file mode 100644 index 00000000..f9831b7a --- /dev/null +++ b/docs/process-versions.md @@ -0,0 +1,75 @@ +# Process versions + +Every photo's develop settings record the **rendering process** they are interpreted by +(`DevelopSettings::process`, a `ProcessVersion` in `crates/develop/src/settings.rs`). Lightroom calls +this the process version. It lets the look of LightCraft improve (a new default tone curve, issue #146; +stronger Light sliders, issue #196) without changing a single photo someone has already edited. + +Today there is one process, **V1**: LightCraft's rendering from before process versions existed. + +## The rule + +**A change that alters how existing settings render ships as a new process. V1 is frozen.** + +- A deliberate change of look (tone model, slider response, default curve, how a stage interprets a + value) goes into a new process. Photos stay on the process they have until someone runs Update to + Current Process (or Reset). +- Bug fixes that make output correct where it was broken (a crash, NaN or black output, a decoder + error, CPU and GPU disagreeing) are not looks and can land in place, as before. +- Changes to how a file is decoded or which camera colour it gets change the source, not how settings + are interpreted; the process doesn't reach that stage today. + +## Adding a process + +1. Add a variant to `Process` (`V2`), append it to `Process::ALL` (oldest first), give it its number in + `Process::version` and make it `Process::LATEST`. +2. The compiler then points at every `match` on `Process`. The base tone map is chosen in one place, + `lightcraft_pipeline::finish::base_tone` (both the CPU and the GPU renderer take it from there): give + V2 its own arm and leave V1's alone. A process that changes another stage branches there the same + way, on `s.process.process()`; if that stage is cached (`Plan::lin_key`, the planes in `local.rs`), + add the process to its key. +3. Keep `settings_saved_before_process_versions_load_as_v1` (`crates/develop/src/tests_process.rs`) + passing: settings saved without the field must stay V1, not become the new latest. That is the + field's `#[serde(default = "ProcessVersion::legacy")]`; the struct-level `Default` gives new + settings the latest instead. +4. Render a few procedural scenes on both processes and compare, and update `docs/parity.md`. + +## What gets which process + +| | Process | +|---|---| +| A newly imported, merged or Lightroom-imported photo | the latest | +| Settings saved without the field (catalogs and their snapshots, versions and history, `lc:settings` in XMP sidecars, browser libraries: everything from before process versions), or with a damaged value | V1 | +| Reset (`develop.reset`) | the latest | +| Reset of a section or a slider | unchanged | +| Update to Current Process (`develop.updateProcess`) | the latest, for photos on an older one; one undo step. The menu item follows the selection; a call with `ids` / `id` acts on those photos whatever is selected | +| Virtual copy, duplicate | the source's | +| Copy / paste / sync / Auto Sync / presets | unchanged (see below) | +| `crs:` edits (XMP from other raw developers, Lightroom catalogs, imported presets) | unchanged; a new photo has the latest | +| Restoring a version or history step | the one it had | + +**Copy, paste, sync and presets** never carry the process: it is in no settings group, so the photos +they change keep their own and interpret the pasted values with it. (Lightroom offers the process +version as a separate checkbox when syncing; LightCraft's groups are coarser and keep it out.) Partial +settings JSON that names `process` itself (`develop.merge`, a hand-written preset) sets it. + +**Lightroom edits** are re-interpreted by LightCraft's mapper (`crates/engine/src/crs.rs`), which is +written against the current rendering. `crs:ProcessVersion` numbers Adobe's renderer, not ours, and is +ignored; a photo imported with Lightroom edits is on LightCraft's latest process, and an existing +LightCraft photo that receives them keeps its own. + +**A number this build doesn't know** (a library or sidecar written by a newer LightCraft) loads, is +kept when saved, and renders with the newest process this build has. Update to Current Process never +moves a photo to an older process; Reset does move it to this build's latest. + +**A damaged value** (anything but a whole number from 0 to 2^32 - 1; `2.0` counts as 2) reads as V1, +like a missing field, and costs nothing else: the rest of the settings load from the catalog, a +snapshot, the op log or an `lc:settings` sidecar as usual. The damaged value isn't kept: the photo is +saved as V1. + +## Storage + +The process is saved as `"process": ` in the settings JSON, except V1, which is left out. +Settings made before process versions existed are therefore saved exactly as before, and their +preview-cache keys (`DevelopSettings::hash64`) don't change. `develop.get` (MCP `get_develop`) always +states the process. diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 73543231..3fb28819 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -26,7 +26,7 @@ What we write (standard namespaces, so other tools can read the metadata): | Capture time, GPS | `exif:DateTimeOriginal`, `photoshop:DateCreated`, `exif:GPSLatitude`/`GPSLongitude` | | Pick / reject flag | `lc:flag` (`pick`, `reject`, `none`) | | Location | `lc:location` | -| Develop settings | `lc:settings` — our complete `DevelopSettings` as JSON (exact round trip, incl. masks, spots, crop) | +| Develop settings | `lc:settings` — our complete `DevelopSettings` as JSON (exact round trip, incl. masks, spots, crop and the rendering process; settings without `process` are V1, see [process-versions.md](process-versions.md)) | `lc:` is `http://ns.lightcraft.app/lc/1.0/`. @@ -100,7 +100,9 @@ Our pipeline renders differently, so **values carry over but the look is approxi We read these fields; we never write them. Only fields in the packet are applied: the result is a partial settings object that gets merged like a preset, so everything else keeps its current or default value. Packets marked `crs:AlreadyApplied="True"` are skipped, because those pixels already contain the edit. Only process-version 2012+ field -names are read (e.g. `Exposure2012`, not the older `Exposure`). +names are read (e.g. `Exposure2012`, not the older `Exposure`). `crs:ProcessVersion` itself numbers the other +application's renderer and is ignored: the photo keeps LightCraft's own process, the latest for a newly imported photo +([process-versions.md](process-versions.md)). | `crs:` field(s) | LightCraft control | Notes | |---|---|---|