mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 10:37:11 -06:00
Each photo's develop settings record the rendering process they are interpreted by (`DevelopSettings::process`). There is one process, V1, the pipeline as it is; nothing renders differently (180 CPU 8/16-bit and GPU renders of procedural scenes, and the saved settings JSON and hash64, are byte-identical to main). Settings saved without the field load as V1 (a field-level serde default), new settings get the latest (`Default`), and V1 is left out when saving, so files and preview-cache keys stay as they were. A damaged value (anything but a whole number up to 2^32 - 1) reads as V1 too, so it never costs the rest of the settings (an unparsable `lc:settings` sidecar is ignored). Reset moves a photo to the latest process, section and slider resets don't; virtual copies inherit it; copy / paste / sync / Auto Sync / presets don't carry it; a number from a newer LightCraft is kept and renders with the newest known process. The process is not an edit (Edited badges, import-look checks). The base tone map is chosen per process in one place (`finish::base_tone`, shared by CPU and GPU). Photo > Update to Current Process (`develop.updateProcess`) moves selected photos on an older process to the latest as one undo step. Its menu enablement follows the selection; a call that names photos (`ids` / `id`) is validated on those instead, through a new opt-in `CommandSpec::explicit_targets` (execute checks enablement before the handler sees `ids`). `develop.get` reports the process. The rule and how to add V2: docs/process-versions.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
10 KiB
Rust
163 lines
10 KiB
Rust
//! 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;
|