mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 04:27:30 -06:00
- engine presets.rs: .lcpreset JSON format {format: "lightcraft.preset", version, presets[]},
parse_preset_file (.lcpreset file / bare preset / array, or XMP), folder expansion,
Session::add_presets (identical presets skipped, id clashes get fresh user.* ids, built-ins
never replaced).
- crs::preset_from_xmp: crs: fields → partial settings, crs:Name/crs:Group (fallback: file name /
"Imported Presets").
- Commands preset.import {paths} and preset.export {path, ids?, group?}; UI commands
file.importPresets / file.exportPresets (File menu, with native open/save dialogs on desktop),
Presets panel "⋯" menu (Import Presets…, Export User Presets…) and group context menu
(Export Group…).
- Tests: export → import round trip keeps groups/settings, duplicates skipped, clashing ids,
malformed files reported, hand-written XMP preset imports and applies.
- docs/xmp-interop.md: preset file section.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
17 KiB
Rust
373 lines
17 KiB
Rust
//! Built-in presets and profiles (all values are our own; no third-party preset content), and
|
|
//! preset files: our `.lcpreset` JSON (import + export, groups preserved) and XMP presets
|
|
//! (`crs:` fields, read only — see [`crate::crs`]).
|
|
|
|
use std::path::Path;
|
|
|
|
use lightcraft_develop::Preset;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::Session;
|
|
|
|
/// File extension of LightCraft preset files.
|
|
pub const LCPRESET_EXT: &str = "lcpreset";
|
|
/// The `format` tag of a `.lcpreset` file.
|
|
pub const LCPRESET_FORMAT: &str = "lightcraft.preset";
|
|
|
|
/// A `.lcpreset` file: one or more presets with their groups.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct PresetFile {
|
|
pub format: String,
|
|
pub version: u32,
|
|
pub presets: Vec<Preset>,
|
|
}
|
|
|
|
/// Lower-case ASCII slug for ids (`"Warm & Soft"` → `warm-soft`).
|
|
pub fn slug(s: &str) -> String {
|
|
let mut out = String::new();
|
|
for c in s.chars() {
|
|
if c.is_ascii_alphanumeric() {
|
|
out.push(c.to_ascii_lowercase());
|
|
} else if !out.ends_with('-') && !out.is_empty() {
|
|
out.push('-');
|
|
}
|
|
}
|
|
let out = out.trim_end_matches('-').to_string();
|
|
if out.is_empty() { "preset".into() } else { out }
|
|
}
|
|
|
|
/// Serialise presets as a `.lcpreset` file (favourite/built-in flags are not exported).
|
|
pub fn to_lcpreset(presets: &[Preset]) -> String {
|
|
let presets = presets.iter().map(|p| Preset { favorite: false, builtin: false, ..p.clone() }).collect();
|
|
serde_json::to_string_pretty(&PresetFile { format: LCPRESET_FORMAT.into(), version: 1, presets }).unwrap_or_default()
|
|
}
|
|
|
|
/// Read a preset file by name: `.lcpreset` (a [`PresetFile`], a bare preset object or an array of
|
|
/// presets) or `.xmp` (a `crs:` preset).
|
|
pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String> {
|
|
let stem = Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Preset".into());
|
|
let ext = Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
|
let text = String::from_utf8_lossy(bytes);
|
|
let text = text.trim_start_matches('\u{feff}');
|
|
if ext == "xmp" || (ext != LCPRESET_EXT && text.trim_start().starts_with('<')) {
|
|
return crate::crs::preset_from_xmp(text, &stem).map(|p| vec![p]).ok_or_else(|| "no develop settings in this XMP file".into());
|
|
}
|
|
let v: Value = serde_json::from_str(text).map_err(|e| format!("not a preset file: {e}"))?;
|
|
let list = match &v {
|
|
Value::Object(o) if o.contains_key("presets") => {
|
|
let f: PresetFile = serde_json::from_value(v.clone()).map_err(|e| e.to_string())?;
|
|
if f.format != LCPRESET_FORMAT {
|
|
return Err(format!("unknown preset format `{}`", f.format));
|
|
}
|
|
f.presets
|
|
}
|
|
Value::Array(_) => serde_json::from_value(v).map_err(|e| e.to_string())?,
|
|
_ => vec![serde_json::from_value(v).map_err(|e| e.to_string())?],
|
|
};
|
|
let mut out = Vec::new();
|
|
for mut p in list {
|
|
if !p.settings.is_object() {
|
|
return Err(format!("preset `{}` has no settings object", p.name));
|
|
}
|
|
p.builtin = false;
|
|
p.favorite = false;
|
|
if p.name.trim().is_empty() {
|
|
p.name = stem.clone();
|
|
}
|
|
if p.group.trim().is_empty() {
|
|
p.group = "Imported Presets".into();
|
|
}
|
|
out.push(p);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Expand files/folders (recursively) into preset files (`.lcpreset`, `.xmp`).
|
|
pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
|
|
fn walk(p: &Path, out: &mut Vec<String>, top: bool) {
|
|
if p.is_dir() {
|
|
let Ok(rd) = std::fs::read_dir(p) else { return };
|
|
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect();
|
|
v.sort();
|
|
for c in v {
|
|
if !c.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) {
|
|
walk(&c, out, false);
|
|
}
|
|
}
|
|
} else {
|
|
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
|
if top || ext == LCPRESET_EXT || ext == "xmp" {
|
|
out.push(p.to_string_lossy().to_string());
|
|
}
|
|
}
|
|
}
|
|
let mut out = Vec::new();
|
|
for p in paths {
|
|
walk(Path::new(p), &mut out, true);
|
|
}
|
|
out
|
|
}
|
|
|
|
impl Session {
|
|
/// Add imported presets. Identical presets (same name, group and settings) already present are
|
|
/// skipped; id clashes get a fresh id. Returns the ids added.
|
|
pub fn add_presets(&mut self, presets: Vec<Preset>) -> Vec<String> {
|
|
let mut added = Vec::new();
|
|
for mut p in presets {
|
|
if self.presets.iter().any(|q| q.name == p.name && q.group == p.group && q.settings == p.settings) {
|
|
continue;
|
|
}
|
|
if p.id.trim().is_empty() || p.id.starts_with("lc.") || self.presets.iter().any(|q| q.id == p.id) {
|
|
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
|
|
let mut id = base.clone();
|
|
let mut n = 2;
|
|
while self.presets.iter().any(|q| q.id == id) {
|
|
id = format!("{base}-{n}");
|
|
n += 1;
|
|
}
|
|
p.id = id;
|
|
}
|
|
p.builtin = false;
|
|
added.push(p.id.clone());
|
|
self.presets.push(p);
|
|
}
|
|
added
|
|
}
|
|
}
|
|
|
|
fn p(group: &str, id: &str, name: &str, settings: serde_json::Value) -> Preset {
|
|
Preset { id: format!("lc.{id}"), name: name.into(), group: group.into(), settings, favorite: false, builtin: true }
|
|
}
|
|
|
|
pub fn builtin() -> Vec<Preset> {
|
|
vec![
|
|
p(
|
|
"Color",
|
|
"warm-glow",
|
|
"Warm Glow",
|
|
json!({"wb": {"mode": "custom", "temp": 7200.0, "tint": 8.0}, "light": {"contrast": 8.0, "shadows": 12.0}, "color": {"vibrance": 18.0}}),
|
|
),
|
|
p(
|
|
"Color",
|
|
"cool-morning",
|
|
"Cool Morning",
|
|
json!({"wb": {"mode": "custom", "temp": 5600.0, "tint": -4.0}, "light": {"highlights": -20.0}, "color": {"vibrance": 10.0}, "grading": {"shadows": {"hue": 215.0, "sat": 12.0, "lum": 0.0}}}),
|
|
),
|
|
p(
|
|
"Color",
|
|
"teal-orange",
|
|
"Teal & Orange",
|
|
json!({"grading": {"shadows": {"hue": 195.0, "sat": 28.0, "lum": 0.0}, "highlights": {"hue": 35.0, "sat": 22.0, "lum": 0.0}, "balance": 10.0}, "color": {"vibrance": 12.0}, "light": {"contrast": 14.0}}),
|
|
),
|
|
p(
|
|
"Color",
|
|
"vivid-pop",
|
|
"Vivid Pop",
|
|
json!({"color": {"vibrance": 40.0, "saturation": 8.0}, "light": {"contrast": 18.0, "whites": 10.0, "blacks": -10.0}, "effects": {"clarity": 12.0}}),
|
|
),
|
|
p(
|
|
"Color",
|
|
"soft-pastel",
|
|
"Soft Pastel",
|
|
json!({"light": {"contrast": -25.0, "highlights": -30.0, "shadows": 35.0, "blacks": 20.0}, "color": {"saturation": -18.0, "vibrance": 10.0}, "curve": {"shadows": 25.0}}),
|
|
),
|
|
p(
|
|
"Film",
|
|
"faded-matte",
|
|
"Faded Matte",
|
|
json!({"curve": {"shadows": 40.0, "highlights": -15.0}, "light": {"contrast": -10.0}, "color": {"saturation": -12.0}, "grain": {"amount": 18.0, "size": 25.0, "roughness": 50.0}}),
|
|
),
|
|
p(
|
|
"Film",
|
|
"warm-film",
|
|
"Warm Film",
|
|
json!({"wb": {"mode": "custom", "temp": 6900.0, "tint": 5.0}, "curve": {"shadows": 22.0}, "grading": {"highlights": {"hue": 45.0, "sat": 15.0, "lum": 0.0}}, "grain": {"amount": 22.0, "size": 30.0, "roughness": 55.0}, "vignette": {"amount": -12.0}}),
|
|
),
|
|
p(
|
|
"Film",
|
|
"muted-film",
|
|
"Muted Film",
|
|
json!({"color": {"saturation": -25.0, "vibrance": 5.0}, "light": {"contrast": 10.0}, "curve": {"shadows": 18.0}, "grain": {"amount": 15.0}}),
|
|
),
|
|
p(
|
|
"B&W",
|
|
"bw-high-contrast",
|
|
"High Contrast B&W",
|
|
json!({"treatment": "bw", "light": {"contrast": 45.0, "whites": 20.0, "blacks": -25.0}, "effects": {"clarity": 20.0}, "bw_mix": {"blue": -30.0, "red": 15.0}}),
|
|
),
|
|
p("B&W", "bw-soft", "Soft B&W", json!({"treatment": "bw", "light": {"contrast": -15.0, "shadows": 25.0}, "curve": {"shadows": 15.0}})),
|
|
p(
|
|
"B&W",
|
|
"bw-selenium",
|
|
"Selenium Tone",
|
|
json!({"treatment": "bw", "light": {"contrast": 20.0}, "grading": {"shadows": {"hue": 285.0, "sat": 18.0, "lum": 0.0}, "highlights": {"hue": 40.0, "sat": 10.0, "lum": 0.0}}}),
|
|
),
|
|
p(
|
|
"Landscape",
|
|
"crisp-landscape",
|
|
"Crisp Landscape",
|
|
json!({"light": {"highlights": -45.0, "shadows": 30.0, "contrast": 12.0}, "effects": {"clarity": 22.0, "dehaze": 12.0, "texture": 15.0}, "color": {"vibrance": 25.0}}),
|
|
),
|
|
p(
|
|
"Landscape",
|
|
"golden-hour",
|
|
"Golden Hour",
|
|
json!({"wb": {"mode": "custom", "temp": 7600.0, "tint": 12.0}, "light": {"highlights": -35.0, "shadows": 20.0}, "grading": {"highlights": {"hue": 38.0, "sat": 25.0, "lum": 5.0}}, "vignette": {"amount": -15.0}}),
|
|
),
|
|
p(
|
|
"Landscape",
|
|
"blue-hour",
|
|
"Blue Hour Boost",
|
|
json!({"wb": {"mode": "custom", "temp": 5200.0, "tint": 6.0}, "light": {"shadows": 25.0, "exposure": 0.2}, "mixer": {"blue": {"hue": 0.0, "sat": 25.0, "lum": 0.0}, "purple": {"hue": 0.0, "sat": 15.0, "lum": 0.0}}}),
|
|
),
|
|
p(
|
|
"Portrait",
|
|
"soft-skin",
|
|
"Soft Skin",
|
|
json!({"effects": {"texture": -25.0, "clarity": -10.0}, "light": {"contrast": -5.0, "shadows": 15.0}, "mixer": {"orange": {"hue": 0.0, "sat": -8.0, "lum": 10.0}}}),
|
|
),
|
|
p(
|
|
"Portrait",
|
|
"bright-airy",
|
|
"Bright & Airy",
|
|
json!({"light": {"exposure": 0.45, "contrast": -15.0, "highlights": -40.0, "shadows": 40.0, "whites": 15.0}, "color": {"vibrance": 8.0, "saturation": -8.0}}),
|
|
),
|
|
p(
|
|
"Style",
|
|
"moody",
|
|
"Moody",
|
|
json!({"light": {"exposure": -0.3, "contrast": 20.0, "highlights": -30.0, "blacks": -15.0}, "color": {"saturation": -20.0}, "grading": {"shadows": {"hue": 200.0, "sat": 15.0, "lum": -5.0}}, "vignette": {"amount": -30.0}}),
|
|
),
|
|
p(
|
|
"Style",
|
|
"cinematic",
|
|
"Cinematic",
|
|
json!({"curve": {"shadows": 20.0, "highlights": -10.0}, "grading": {"shadows": {"hue": 190.0, "sat": 25.0, "lum": 0.0}, "highlights": {"hue": 30.0, "sat": 18.0, "lum": 0.0}}, "light": {"contrast": 15.0}, "vignette": {"amount": -20.0}}),
|
|
),
|
|
]
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct ProfileInfo {
|
|
pub id: &'static str,
|
|
pub name: &'static str,
|
|
pub group: &'static str,
|
|
}
|
|
|
|
/// Our base profiles (rendering looks). Implemented in the pipeline.
|
|
pub const PROFILES: &[ProfileInfo] = &[
|
|
ProfileInfo { id: "lc.color", name: "Color", group: "Basic" },
|
|
ProfileInfo { id: "lc.neutral", name: "Neutral", group: "Basic" },
|
|
ProfileInfo { id: "lc.vivid", name: "Vivid", group: "Basic" },
|
|
ProfileInfo { id: "lc.landscape", name: "Landscape", group: "Basic" },
|
|
ProfileInfo { id: "lc.portrait", name: "Portrait", group: "Basic" },
|
|
ProfileInfo { id: "lc.mono", name: "Monochrome", group: "Basic" },
|
|
];
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn builtin_presets_parse_and_apply() {
|
|
let base = lightcraft_develop::DevelopSettings::default();
|
|
let mut ids = std::collections::HashSet::new();
|
|
for pr in builtin() {
|
|
assert!(ids.insert(pr.id.clone()));
|
|
let out = pr.apply(&base, 1.0);
|
|
assert_ne!(out, base, "{} had no effect", pr.id);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn slugs() {
|
|
assert_eq!(slug("Warm & Soft!"), "warm-soft");
|
|
assert_eq!(slug(" "), "preset");
|
|
assert_eq!(slug("B&W/Film 2"), "b-w-film-2");
|
|
}
|
|
|
|
fn dir(tag: &str) -> std::path::PathBuf {
|
|
let d = std::env::temp_dir().join(format!("lc-presets-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn lcpreset_export_import_roundtrip_keeps_groups() {
|
|
let d = dir("rt");
|
|
let mut s = Session::with_demo();
|
|
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.6})).unwrap();
|
|
s.execute("preset.create", &json!({"name": "Bright", "group": "Travel"})).unwrap();
|
|
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 30})).unwrap();
|
|
s.execute("preset.create", &json!({"name": "Crisp", "group": "Street"})).unwrap();
|
|
let mine: Vec<Preset> = s.presets.iter().filter(|p| !p.builtin).cloned().collect();
|
|
assert_eq!(mine.len(), 2);
|
|
let path = d.join("mine");
|
|
let r = s.execute("preset.export", &json!({"path": path.to_string_lossy()})).unwrap();
|
|
assert_eq!(r["count"], 2);
|
|
let file = d.join("mine.lcpreset");
|
|
assert!(file.is_file());
|
|
let one = d.join("travel.lcpreset");
|
|
s.execute("preset.export", &json!({"path": one.to_string_lossy(), "group": "Travel"})).unwrap();
|
|
assert!(s.execute("preset.export", &json!({"path": one.to_string_lossy(), "group": "Nope"})).is_err());
|
|
|
|
// a fresh session imports both files from the folder; the duplicate is skipped
|
|
let mut t = Session::new();
|
|
let r = t.execute("preset.import", &json!({"paths": [d.to_string_lossy()]})).unwrap();
|
|
assert_eq!(r["imported"].as_array().unwrap().len(), 2, "{r}");
|
|
assert_eq!(r["skipped"], 1, "{r}");
|
|
for p in &mine {
|
|
let q = t.presets.iter().find(|q| q.name == p.name).unwrap();
|
|
assert_eq!((&q.group, &q.settings, q.builtin), (&p.group, &p.settings, false));
|
|
}
|
|
// importing into the session that has them: everything is skipped, nothing duplicated
|
|
let n = s.presets.len();
|
|
let r = s.execute("preset.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
|
assert_eq!((r["skipped"].as_u64(), s.presets.len()), (Some(2), n));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn id_clashes_get_fresh_ids_and_bad_files_are_reported() {
|
|
let d = dir("clash");
|
|
let builtin = builtin().remove(0);
|
|
let mut clash = builtin.clone();
|
|
clash.settings = json!({"light": {"exposure": 1.0}});
|
|
std::fs::write(d.join("a.lcpreset"), serde_json::to_string(&clash).unwrap()).unwrap();
|
|
std::fs::write(d.join("b.lcpreset"), "{not json").unwrap();
|
|
std::fs::write(d.join("c.lcpreset"), r#"{"format": "other", "version": 1, "presets": []}"#).unwrap();
|
|
std::fs::write(d.join("notes.txt"), "ignored inside folders").unwrap();
|
|
let mut s = Session::new();
|
|
let r = s.execute("preset.import", &json!({"paths": [d.to_string_lossy()]})).unwrap();
|
|
assert_eq!(r["failed"].as_array().unwrap().len(), 2, "{r}");
|
|
let id = r["imported"][0]["id"].as_str().unwrap();
|
|
assert_ne!(id, builtin.id);
|
|
assert!(id.starts_with("user."));
|
|
assert!(s.presets.iter().find(|p| p.id == builtin.id).unwrap().builtin, "built-in untouched");
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn xmp_preset_file_imports_and_applies() {
|
|
let d = dir("xmp");
|
|
// Hand-written for this test (not a third-party preset).
|
|
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
|
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
|
crs:PresetType="Normal" crs:Exposure2012="+0.30" crs:Shadows2012="+20" crs:GrainAmount="12">
|
|
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Test Group</rdf:li></rdf:Alt></crs:Group>
|
|
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
|
std::fs::write(d.join("Soft Lift.xmp"), x).unwrap();
|
|
let mut s = Session::with_demo();
|
|
let r = s.execute("preset.import", &json!({"paths": [d.join("Soft Lift.xmp").to_string_lossy()]})).unwrap();
|
|
let id = r["imported"][0]["id"].as_str().unwrap().to_string();
|
|
assert_eq!(r["imported"][0]["name"], "Soft Lift", "file name when crs:Name is missing");
|
|
assert_eq!(r["imported"][0]["group"], "Test Group");
|
|
s.execute("preset.apply", &json!({"id": id})).unwrap();
|
|
let dv = s.develop_of(s.active().unwrap()).unwrap();
|
|
assert_eq!((dv.light.exposure, dv.light.shadows, dv.grain.amount), (0.3, 20.0, 12.0));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
}
|