# Conflicts:
#	docs/parity.md
This commit is contained in:
Brandon Thomas committed 2026-10-09 20:50:41 -04:00
commit 6f323e01d9
22 files changed
+873 -34

No files matched your search

+2 -1
View File
@@ -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
+34 -3
View File
@@ -352,15 +352,19 @@ impl Photo {
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`]).
/// 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 {
@@ -370,6 +374,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).
@@ -446,6 +451,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 [
+41
View File
@@ -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));
}
+6 -1
View File
@@ -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::*;
+4
View File
@@ -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};
+97 -1
View File
@@ -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: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[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(),
+162
View File
@@ -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<DevelopSettings> = 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<u32> = 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<DevelopSettings> = 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> = 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;
+77 -2
View File
@@ -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<Option<Vec<PhotoId>>> {
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::<Result<Vec<_>>>()?;
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<CommandSpec> {
}
}
};
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<Op> = 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",
+11 -2
View File
@@ -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<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
+11 -2
View File
@@ -149,9 +149,18 @@ pub fn specs() -> Vec<CommandSpec> {
}
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();
+9 -3
View File
@@ -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<Value> {
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<Op> {
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;
+262
View File
@@ -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::<Vec<_>>()})).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<PhotoId> = 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<PhotoId> = 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<PhotoId> = 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<PhotoId> = 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#"<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:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:ProcessVersion="11.0" crs:Exposure2012="+1.10" crs:Contrast2012="+20"/></rdf:RDF></x:xmpmeta>"#;
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<PhotoId> = 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(..))));
}
+4 -3
View File
@@ -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<f32> = 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]);
+17 -8
View File
@@ -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<crate::Proof>) -> 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);
+2 -2
View File
@@ -583,9 +583,9 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option<Plane>
}
/// 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
}
+46
View File
@@ -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");
}
}
}
}
+1
View File
@@ -184,6 +184,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
"develop.auto",
"develop.treatment",
"develop.reset",
"develop.updateProcess",
"dialog.createPreset",
"---",
"photo.saveMetadataToFile",
+3 -1
View File
@@ -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.
A Custom white balance on a raw LightCraft develops relative to its as-shot look (ARW, NEF, RW2,
+1 -1
View File
@@ -146,7 +146,7 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
| `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` |
+4 -2
View File
@@ -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:<albums\|local\|byDate\|keywords>`) 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 | ⬜ | | |
+75
View File
@@ -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": <number>` 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.
+4 -2
View File
@@ -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 |
|---|---|---|