mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 10:37:11 -06:00
706 lines
31 KiB
Rust
706 lines
31 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>,
|
||
}
|
||
|
||
/// The session's presets (built-in, then the user's) with a generation that changes on every
|
||
/// mutable access, so saving and the UI's caches skip their O(presets) work while the list is
|
||
/// untouched: a Lightroom collection imported whole is 10 000+ presets and ~40 MB of JSON.
|
||
///
|
||
/// Reads go through `Deref` (`iter`, `len`, `find`…) and keep the generation; `push`, `iter_mut`,
|
||
/// `remove`… go through `DerefMut` and bump it, whether or not anything is then changed.
|
||
/// Generations are unique across all lists in the process.
|
||
#[derive(Clone, Debug)]
|
||
pub struct PresetList {
|
||
items: Vec<Preset>,
|
||
generation: u64,
|
||
}
|
||
|
||
static PRESET_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||
|
||
fn next_generation() -> u64 {
|
||
PRESET_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||
}
|
||
|
||
impl PresetList {
|
||
pub fn new(items: Vec<Preset>) -> PresetList {
|
||
PresetList { items, generation: next_generation() }
|
||
}
|
||
|
||
/// Changes whenever the list may have changed.
|
||
pub fn generation(&self) -> u64 {
|
||
self.generation
|
||
}
|
||
}
|
||
|
||
impl Default for PresetList {
|
||
fn default() -> PresetList {
|
||
PresetList::new(Vec::new())
|
||
}
|
||
}
|
||
|
||
impl std::ops::Deref for PresetList {
|
||
type Target = Vec<Preset>;
|
||
fn deref(&self) -> &Vec<Preset> {
|
||
&self.items
|
||
}
|
||
}
|
||
|
||
impl std::ops::DerefMut for PresetList {
|
||
fn deref_mut(&mut self) -> &mut Vec<Preset> {
|
||
self.generation = next_generation();
|
||
&mut self.items
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoIterator for &'a PresetList {
|
||
type Item = &'a Preset;
|
||
type IntoIter = std::slice::Iter<'a, Preset>;
|
||
fn into_iter(self) -> Self::IntoIter {
|
||
self.items.iter()
|
||
}
|
||
}
|
||
|
||
impl<'a> IntoIterator for &'a mut PresetList {
|
||
type Item = &'a mut Preset;
|
||
type IntoIter = std::slice::IterMut<'a, Preset>;
|
||
fn into_iter(self) -> Self::IntoIter {
|
||
self.generation = next_generation();
|
||
self.items.iter_mut()
|
||
}
|
||
}
|
||
|
||
/// 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, bounded: see [`crate::walk`]) into preset files
|
||
/// (`.lcpreset`, `.xmp`).
|
||
pub fn expand_preset_paths(paths: &[String]) -> Vec<String> {
|
||
let preset_file = |p: &Path| {
|
||
let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||
ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack", crate::cmd::mask_effect_files::EFFECT_EXT].contains(&ext.as_str())
|
||
};
|
||
let mut out = Vec::new();
|
||
for p in paths {
|
||
let path = Path::new(p);
|
||
if path.is_dir() {
|
||
let w = crate::walk::files_in(path, None, crate::walk::Limits::default(), preset_file);
|
||
out.extend(w.files.into_iter().map(|f| f.to_string_lossy().to_string()));
|
||
} else {
|
||
out.push(p.clone());
|
||
}
|
||
}
|
||
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> {
|
||
use std::collections::{HashMap, HashSet};
|
||
// indexed: a collection of 10 000+ presets was quadratic (minutes) to import
|
||
let mut ids: HashSet<String> = self.presets.iter().map(|q| q.id.clone()).collect();
|
||
let mut by_name: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||
for (i, q) in self.presets.iter().enumerate() {
|
||
by_name.entry((q.name.clone(), q.group.clone())).or_default().push(i);
|
||
}
|
||
let mut added = Vec::new();
|
||
for mut p in presets {
|
||
let key = (p.name.clone(), p.group.clone());
|
||
let same = by_name.get(&key).is_some_and(|ix| ix.iter().any(|&i| self.presets.get(i).is_some_and(|q| q.settings == p.settings)));
|
||
if same {
|
||
continue;
|
||
}
|
||
if p.id.trim().is_empty() || p.id.starts_with("lc.") || ids.contains(&p.id) {
|
||
let base = format!("user.{}", slug(&format!("{}-{}", p.group, p.name)));
|
||
let mut id = base.clone();
|
||
let mut n = 2;
|
||
while ids.contains(&id) {
|
||
id = format!("{base}-{n}");
|
||
n += 1;
|
||
}
|
||
p.id = id;
|
||
}
|
||
p.builtin = false;
|
||
ids.insert(p.id.clone());
|
||
by_name.entry(key).or_default().push(self.presets.len());
|
||
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}}),
|
||
),
|
||
// ---- Portrait
|
||
p(
|
||
"Portrait",
|
||
"moody-portrait",
|
||
"Moody Portrait",
|
||
json!({"light": {"exposure": -0.25, "contrast": 20.0, "highlights": -30.0, "blacks": -12.0}, "color": {"saturation": -15.0}, "vignette": {"amount": -25.0, "midpoint": 40.0}, "mixer": {"orange": {"sat": 6.0}}}),
|
||
),
|
||
p(
|
||
"Portrait",
|
||
"golden-glow",
|
||
"Golden Glow",
|
||
json!({"grading": {"highlights": {"hue": 40.0, "sat": 18.0, "lum": 0.0}, "midtones": {"hue": 35.0, "sat": 8.0, "lum": 0.0}}, "light": {"shadows": 15.0}, "effects": {"clarity": -8.0}}),
|
||
),
|
||
p(
|
||
"Portrait",
|
||
"clean-studio",
|
||
"Clean Studio",
|
||
json!({"light": {"contrast": 10.0, "whites": 15.0, "blacks": -8.0}, "effects": {"texture": -10.0}, "color": {"vibrance": 5.0}}),
|
||
),
|
||
// ---- Landscape
|
||
p(
|
||
"Landscape",
|
||
"crisp-vista",
|
||
"Crisp Vista",
|
||
json!({"effects": {"dehaze": 15.0, "clarity": 18.0, "texture": 12.0}, "light": {"contrast": 12.0, "highlights": -25.0, "shadows": 18.0}, "color": {"vibrance": 20.0}}),
|
||
),
|
||
p(
|
||
"Landscape",
|
||
"deep-sky",
|
||
"Deep Sky",
|
||
json!({"mixer": {"blue": {"lum": -25.0, "sat": 15.0}, "aqua": {"lum": -10.0}}, "light": {"highlights": -30.0}, "effects": {"dehaze": 10.0}}),
|
||
),
|
||
p(
|
||
"Landscape",
|
||
"lush-greens",
|
||
"Lush Greens",
|
||
json!({"mixer": {"green": {"hue": 12.0, "sat": 18.0, "lum": 8.0}, "yellow": {"hue": 10.0, "sat": 10.0}}, "color": {"vibrance": 12.0}, "effects": {"clarity": 8.0}}),
|
||
),
|
||
p(
|
||
"Landscape",
|
||
"desert-warmth",
|
||
"Desert Warmth",
|
||
json!({"mixer": {"orange": {"sat": 15.0, "lum": 5.0}, "yellow": {"hue": -8.0, "sat": 10.0}, "blue": {"sat": -10.0}}, "grading": {"highlights": {"hue": 38.0, "sat": 12.0, "lum": 0.0}}, "light": {"contrast": 10.0}}),
|
||
),
|
||
p(
|
||
"Landscape",
|
||
"misty-morning",
|
||
"Misty Morning",
|
||
json!({"effects": {"dehaze": -18.0, "clarity": -12.0}, "light": {"contrast": -18.0, "highlights": -10.0}, "grading": {"shadows": {"hue": 210.0, "sat": 10.0, "lum": 0.0}}, "color": {"saturation": -10.0}}),
|
||
),
|
||
// ---- Urban
|
||
p(
|
||
"Urban",
|
||
"gritty-street",
|
||
"Gritty Street",
|
||
json!({"effects": {"clarity": 35.0, "texture": 20.0}, "light": {"contrast": 25.0, "blacks": -15.0}, "color": {"saturation": -30.0}, "vignette": {"amount": -18.0}}),
|
||
),
|
||
p(
|
||
"Urban",
|
||
"neon-night",
|
||
"Neon Night",
|
||
json!({"mixer": {"magenta": {"sat": 20.0}, "purple": {"sat": 18.0}, "blue": {"sat": 12.0}, "aqua": {"sat": 15.0}}, "light": {"contrast": 18.0, "blacks": -12.0}, "color": {"vibrance": 15.0}, "grading": {"shadows": {"hue": 250.0, "sat": 15.0, "lum": 0.0}}}),
|
||
),
|
||
p(
|
||
"Urban",
|
||
"concrete-cool",
|
||
"Concrete Cool",
|
||
json!({"grading": {"shadows": {"hue": 205.0, "sat": 14.0, "lum": 0.0}, "highlights": {"hue": 200.0, "sat": 6.0, "lum": 0.0}}, "color": {"saturation": -20.0}, "light": {"contrast": 12.0}, "effects": {"clarity": 15.0}}),
|
||
),
|
||
p(
|
||
"Urban",
|
||
"faded-urban",
|
||
"Faded Urban",
|
||
json!({"curve": {"shadows": 30.0}, "color": {"saturation": -22.0}, "light": {"contrast": -8.0}, "grain": {"amount": 12.0, "size": 25.0, "roughness": 45.0}}),
|
||
),
|
||
// ---- Food
|
||
p(
|
||
"Food",
|
||
"fresh-bright",
|
||
"Fresh & Bright",
|
||
json!({"light": {"exposure": 0.25, "shadows": 20.0, "whites": 10.0}, "color": {"vibrance": 22.0}, "effects": {"texture": 15.0}}),
|
||
),
|
||
p(
|
||
"Food",
|
||
"warm-table",
|
||
"Warm Table",
|
||
json!({"grading": {"midtones": {"hue": 32.0, "sat": 10.0, "lum": 0.0}}, "mixer": {"orange": {"sat": 10.0}, "red": {"sat": 8.0}}, "light": {"contrast": 8.0}}),
|
||
),
|
||
p(
|
||
"Food",
|
||
"dark-moody-food",
|
||
"Dark & Moody",
|
||
json!({"light": {"exposure": -0.3, "contrast": 22.0, "highlights": -20.0, "blacks": -15.0}, "effects": {"texture": 18.0}, "vignette": {"amount": -22.0}}),
|
||
),
|
||
// ---- Seasons
|
||
p(
|
||
"Seasons",
|
||
"autumn-gold",
|
||
"Autumn Gold",
|
||
json!({"mixer": {"green": {"hue": -30.0, "sat": -10.0}, "yellow": {"hue": -15.0, "sat": 15.0}, "orange": {"sat": 18.0}}, "grading": {"highlights": {"hue": 40.0, "sat": 10.0, "lum": 0.0}}}),
|
||
),
|
||
p(
|
||
"Seasons",
|
||
"winter-blue",
|
||
"Winter Blue",
|
||
json!({"grading": {"shadows": {"hue": 215.0, "sat": 18.0, "lum": 0.0}, "highlights": {"hue": 205.0, "sat": 8.0, "lum": 0.0}}, "color": {"saturation": -12.0}, "light": {"whites": 12.0}}),
|
||
),
|
||
p(
|
||
"Seasons",
|
||
"spring-fresh",
|
||
"Spring Fresh",
|
||
json!({"mixer": {"green": {"hue": 10.0, "sat": 12.0, "lum": 10.0}, "magenta": {"sat": 10.0}}, "light": {"shadows": 15.0}, "color": {"vibrance": 15.0}}),
|
||
),
|
||
p(
|
||
"Seasons",
|
||
"summer-haze",
|
||
"Summer Haze",
|
||
json!({"effects": {"dehaze": -12.0}, "curve": {"shadows": 18.0}, "grading": {"highlights": {"hue": 45.0, "sat": 14.0, "lum": 0.0}}, "light": {"contrast": -10.0}}),
|
||
),
|
||
// ---- Vintage
|
||
p(
|
||
"Vintage",
|
||
"instant-70s",
|
||
"Instant ’70s",
|
||
json!({"curve": {"shadows": 28.0, "highlights": -18.0}, "grading": {"shadows": {"hue": 170.0, "sat": 14.0, "lum": 0.0}, "highlights": {"hue": 45.0, "sat": 18.0, "lum": 0.0}}, "color": {"saturation": -10.0}, "vignette": {"amount": -15.0}, "grain": {"amount": 20.0, "size": 30.0, "roughness": 55.0}}),
|
||
),
|
||
p(
|
||
"Vintage",
|
||
"cross-process",
|
||
"Cross Process",
|
||
json!({"grading": {"shadows": {"hue": 230.0, "sat": 25.0, "lum": 0.0}, "highlights": {"hue": 60.0, "sat": 25.0, "lum": 0.0}}, "light": {"contrast": 22.0}, "color": {"saturation": 10.0}}),
|
||
),
|
||
p(
|
||
"Vintage",
|
||
"bleach-bypass",
|
||
"Bleach Bypass",
|
||
json!({"color": {"saturation": -40.0}, "light": {"contrast": 35.0, "highlights": -15.0}, "effects": {"clarity": 15.0}}),
|
||
),
|
||
// ---- B&W toners
|
||
p(
|
||
"B&W",
|
||
"bw-sepia",
|
||
"Sepia Tone",
|
||
json!({"treatment": "bw", "grading": {"shadows": {"hue": 32.0, "sat": 22.0, "lum": 0.0}, "highlights": {"hue": 42.0, "sat": 18.0, "lum": 0.0}}, "curve": {"shadows": 10.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" },
|
||
ProfileInfo { id: "lc.film.warm-print", name: "Warm Print", group: "Film" },
|
||
ProfileInfo { id: "lc.film.cool-fade", name: "Cool Fade", group: "Film" },
|
||
ProfileInfo { id: "lc.film.golden-hour", name: "Golden Hour", group: "Film" },
|
||
ProfileInfo { id: "lc.film.faded-slide", name: "Faded Slide", group: "Film" },
|
||
ProfileInfo { id: "lc.cine.teal-amber", name: "Teal & Amber", group: "Cinematic" },
|
||
ProfileInfo { id: "lc.cine.night-blue", name: "Night Blue", group: "Cinematic" },
|
||
ProfileInfo { id: "lc.cine.desert-heat", name: "Desert Heat", group: "Cinematic" },
|
||
ProfileInfo { id: "lc.cine.neon-dusk", name: "Neon Dusk", group: "Cinematic" },
|
||
ProfileInfo { id: "lc.muted.matte-soft", name: "Matte Soft", group: "Muted" },
|
||
ProfileInfo { id: "lc.muted.bleached", name: "Bleached", group: "Muted" },
|
||
ProfileInfo { id: "lc.muted.pastel-haze", name: "Pastel Haze", group: "Muted" },
|
||
ProfileInfo { id: "lc.muted.quiet-green", name: "Quiet Green", group: "Muted" },
|
||
ProfileInfo { id: "lc.bw.mono-rich", name: "Mono Rich", group: "B&W" },
|
||
ProfileInfo { id: "lc.bw.red-filter", name: "Mono Red Filter", group: "B&W" },
|
||
ProfileInfo { id: "lc.bw.soft", name: "Mono Soft", group: "B&W" },
|
||
ProfileInfo { id: "lc.bw.sepia", name: "Sepia Tone", group: "B&W" },
|
||
];
|
||
|
||
/// Profile groups in menu/browser order.
|
||
pub fn profile_groups() -> Vec<&'static str> {
|
||
let mut out: Vec<&'static str> = Vec::new();
|
||
for p in PROFILES {
|
||
if !out.contains(&p.group) {
|
||
out.push(p.group);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// A profile by id.
|
||
pub fn profile(id: &str) -> Option<&'static ProfileInfo> {
|
||
PROFILES.iter().find(|p| p.id == id)
|
||
}
|
||
|
||
/// How many recently used profiles the profile menu lists.
|
||
pub const RECENT_PROFILES: usize = 5;
|
||
|
||
impl Session {
|
||
/// A profile by id, looking up built-in profiles first and imported LUT profiles second.
|
||
pub fn profile_info(&self, id: &str) -> Option<(&str, &str)> {
|
||
if let Some(p) = profile(id) {
|
||
return Some((p.name, p.group));
|
||
}
|
||
self.lut_profiles.iter().find(|p| p.id == id).map(|p| (p.name.as_str(), p.group.as_str()))
|
||
}
|
||
|
||
/// Remember `id` as the most recently applied profile.
|
||
pub fn note_profile_used(&mut self, id: &str) {
|
||
self.profile_recent.retain(|p| p != id);
|
||
self.profile_recent.insert(0, id.to_string());
|
||
self.profile_recent.truncate(RECENT_PROFILES);
|
||
}
|
||
|
||
/// The profile menu: favourites, recent, then every group (`profiles.menu`).
|
||
pub fn profile_menu(&self) -> Value {
|
||
let item =
|
||
|p: &ProfileInfo| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.iter().any(|f| f == p.id)});
|
||
let lut_item = |id: &str| {
|
||
self.lut_profiles
|
||
.iter()
|
||
.find(|p| p.id == id)
|
||
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.contains(&p.id), "imported": true}))
|
||
};
|
||
let list = |ids: &[String]| ids.iter().filter_map(|id| profile(id).map(item).or_else(|| lut_item(id))).collect::<Vec<_>>();
|
||
let mut groups: Vec<Value> = profile_groups()
|
||
.into_iter()
|
||
.map(|g| json!({"name": g, "profiles": PROFILES.iter().filter(|p| p.group == g).map(item).collect::<Vec<_>>()}))
|
||
.collect();
|
||
// imported LUT profiles, by their groups
|
||
let mut lut_groups: Vec<&str> = self.lut_profiles.iter().map(|p| p.group.as_str()).collect();
|
||
lut_groups.sort_unstable();
|
||
lut_groups.dedup();
|
||
for g in lut_groups {
|
||
let profiles: Vec<Value> = self
|
||
.lut_profiles
|
||
.iter()
|
||
.filter(|p| p.group == g)
|
||
.map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": self.profile_favorites.contains(&p.id), "imported": true}))
|
||
.collect();
|
||
groups.push(json!({"name": g, "profiles": profiles, "imported": true}));
|
||
}
|
||
json!({
|
||
"favorites": list(&self.profile_favorites),
|
||
"recent": list(&self.profile_recent),
|
||
"groups": groups,
|
||
})
|
||
}
|
||
}
|
||
|
||
#[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 every_profile_has_a_look_and_ids_are_unique() {
|
||
let looks = crate::pipeline::profiles::LOOK_IDS;
|
||
let mut ids = std::collections::HashSet::new();
|
||
for p in PROFILES {
|
||
assert!(ids.insert(p.id), "{}", p.id);
|
||
assert!(p.id == "lc.color" || looks.contains(&p.id), "{} has no look", p.id);
|
||
}
|
||
assert!(PROFILES.len() >= 22);
|
||
assert_eq!(profile_groups(), ["Basic", "Film", "Cinematic", "Muted", "B&W"]);
|
||
}
|
||
|
||
#[test]
|
||
fn creative_profiles_render_without_moving_the_sliders() {
|
||
let mut s = Session::with_demo();
|
||
let id = s.active().unwrap();
|
||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap();
|
||
let before = s.develop_of(id).unwrap();
|
||
let plain = s.render_now(id, 96, 96).unwrap().image;
|
||
s.execute("develop.profile", &json!({"id": "lc.cine.teal-amber", "amount": 150})).unwrap();
|
||
let after = s.develop_of(id).unwrap();
|
||
assert_eq!((after.profile.id.as_str(), after.profile.amount), ("lc.cine.teal-amber", 150.0));
|
||
let mut same = (*after).clone();
|
||
same.profile = before.profile.clone();
|
||
assert_eq!(same, *before, "only the profile changed");
|
||
let looked = s.render_now(id, 96, 96).unwrap().image;
|
||
assert_ne!(plain.as_bytes(), looked.as_bytes());
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|