LR-EDIT-CURVE: point-curve presets (built-in, save, delete, import/export)
A Point Curve dropdown under the curve graph shows the preset the photo's point curves match (or Custom) and lists our own built-ins (Linear, Medium Contrast, Strong Contrast: symmetric S-curves, values chosen here) and the user's presets; Save Point Curve... names and stores the current four channels, right-click deletes, Import/Export Presets... read and write .lccurve JSON files. Presets set only the point curves (parametric curve and Refine Saturation stay) and are saved with the library preferences. Engine commands: curve.presets, curve.applyPreset, curve.savePreset, curve.deletePreset, curve.importPresets, curve.exportPresets; UI commands file.importCurvePresets / file.exportCurvePresets (file dialogs on desktop). Fixes #14 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ae29c0569f
commit
966db71295
|
|
@ -154,6 +154,8 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
|
|||
// Preset files: browser pickers are asynchronous; not wired on the web yet.
|
||||
pick_preset_files: None,
|
||||
save_preset_file: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
write: Some(Box::new(download)),
|
||||
// downloads happen on the main thread: exports run in the foreground on the web
|
||||
write_shared: None,
|
||||
|
|
|
|||
|
|
@ -181,6 +181,24 @@ fn services() -> Services {
|
|||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_curve_preset_files: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Import Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve", "json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
save_curve_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title("Export Point Curve Presets")
|
||||
.add_filter("Point Curve Presets", &["lccurve"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| {
|
||||
if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
//! Tone-curve commands: reset a channel or the whole curve.
|
||||
//! Tone-curve commands: reset a channel or the whole curve; point-curve presets (built-in Linear /
|
||||
//! Medium / Strong Contrast plus the user's, saved with the library preferences, imported and
|
||||
//! exported as small JSON files).
|
||||
|
||||
use lightcraft_develop::ToneCurve;
|
||||
use lightcraft_geom::Point;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_active, str_param};
|
||||
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Channels `curve.reset` accepts.
|
||||
|
|
@ -46,13 +50,308 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"curve.reset",
|
||||
"Reset Tone Curve",
|
||||
[],
|
||||
None,
|
||||
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
|
||||
has_active,
|
||||
reset
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
"curve.reset",
|
||||
"Reset Tone Curve",
|
||||
[],
|
||||
None,
|
||||
"{channel?: all|point|parametric|master|red|green|blue (default all)} — the active photo's tone curve (or one channel) back to linear",
|
||||
has_active,
|
||||
reset
|
||||
),
|
||||
cmd!(
|
||||
query "curve.presets",
|
||||
"Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {presets: [{name, master, red, green, blue: [[x,y],...], builtin?}], current: name of the preset the active photo's point curves match, or null}",
|
||||
always,
|
||||
|s, _| Ok(list(s))
|
||||
),
|
||||
cmd!(
|
||||
"curve.applyPreset",
|
||||
"Apply Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — sets the active photo's point curves (all four channels; the parametric curve stays)",
|
||||
has_active,
|
||||
apply_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.savePreset",
|
||||
"Save Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — the active photo's point curves under a name (replaces a user preset of that name; built-in names are reserved), saved with the library",
|
||||
has_active,
|
||||
save_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.deletePreset",
|
||||
"Delete Point Curve Preset",
|
||||
[],
|
||||
None,
|
||||
"{name} — a user preset (built-ins can't be deleted)",
|
||||
always,
|
||||
delete_preset
|
||||
),
|
||||
cmd!(
|
||||
"curve.importPresets",
|
||||
"Import Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path | paths: [file]} — .lccurve JSON files ({format, version, presets: [...]}, an array of presets, or one preset); same-named user presets are replaced → {imported: [name], failed: [[file, error]]}",
|
||||
always,
|
||||
import_presets
|
||||
),
|
||||
cmd!(
|
||||
"curve.exportPresets",
|
||||
"Export Point Curve Presets",
|
||||
[],
|
||||
None,
|
||||
"{path, names?: [name] (default: every user preset)} — writes a .lccurve JSON file → {path, count}",
|
||||
always,
|
||||
export_presets
|
||||
),
|
||||
]
|
||||
}
|
||||
// ---------------------------------------------------------------- point-curve presets
|
||||
|
||||
/// File extension of exported curve presets (JSON).
|
||||
pub const CURVE_PRESET_EXT: &str = "lccurve";
|
||||
/// `format` tag of an exported curve-preset file.
|
||||
const FILE_FORMAT: &str = "lightcraft.curvePresets";
|
||||
|
||||
/// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear).
|
||||
/// Built-ins ship with the app; the user's are saved with the library preferences.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CurvePreset {
|
||||
pub name: String,
|
||||
pub master: Vec<[f64; 2]>,
|
||||
pub red: Vec<[f64; 2]>,
|
||||
pub green: Vec<[f64; 2]>,
|
||||
pub blue: Vec<[f64; 2]>,
|
||||
/// Built-in presets can't be deleted or overwritten (never stored).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// Points clamped to 0..1, sorted by x, duplicates in x dropped; a linear curve becomes empty.
|
||||
fn normalize(v: &[[f64; 2]]) -> Vec<[f64; 2]> {
|
||||
let mut out: Vec<[f64; 2]> =
|
||||
v.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0)]).collect();
|
||||
out.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
out.dedup_by(|a, b| (a[0] - b[0]).abs() < 1e-6);
|
||||
if out.len() < 2 || out.iter().all(|p| (p[0] - p[1]).abs() < 1e-9) {
|
||||
out.clear();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn from_points(v: &[Point]) -> Vec<[f64; 2]> {
|
||||
normalize(&v.iter().map(|p| [p.x, p.y]).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn to_points(v: &[[f64; 2]]) -> Vec<Point> {
|
||||
normalize(v).iter().map(|p| Point::new(p[0], p[1])).collect()
|
||||
}
|
||||
|
||||
impl CurvePreset {
|
||||
/// The point curves of `c` under `name`.
|
||||
pub fn from_curve(name: &str, c: &ToneCurve) -> CurvePreset {
|
||||
CurvePreset {
|
||||
name: name.into(),
|
||||
master: from_points(&c.master),
|
||||
red: from_points(&c.red),
|
||||
green: from_points(&c.green),
|
||||
blue: from_points(&c.blue),
|
||||
builtin: false,
|
||||
}
|
||||
}
|
||||
/// Set the four point curves of `c` (the parametric curve and Refine Saturation stay).
|
||||
pub fn apply(&self, c: &mut ToneCurve) {
|
||||
c.master = to_points(&self.master);
|
||||
c.red = to_points(&self.red);
|
||||
c.green = to_points(&self.green);
|
||||
c.blue = to_points(&self.blue);
|
||||
}
|
||||
/// Does `c` carry exactly this preset's point curves?
|
||||
pub fn matches(&self, c: &ToneCurve) -> bool {
|
||||
let same = |a: &[[f64; 2]], b: &[Point]| {
|
||||
let (a, b) = (normalize(a), from_points(b));
|
||||
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p[0] - q[0]).abs() < 1e-6 && (p[1] - q[1]).abs() < 1e-6)
|
||||
};
|
||||
same(&self.master, &c.master) && same(&self.red, &c.red) && same(&self.green, &c.green) && same(&self.blue, &c.blue)
|
||||
}
|
||||
fn is_linear(&self) -> bool {
|
||||
[&self.master, &self.red, &self.green, &self.blue].iter().all(|v| normalize(v).is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// The presets that ship with the app: a linear curve and two symmetric S-curves (our own
|
||||
/// values: the quarter-tones pulled 0.05 / 0.11 towards the ends, midpoint fixed).
|
||||
pub fn builtin_presets() -> Vec<CurvePreset> {
|
||||
let s = |name: &str, master: Vec<[f64; 2]>| CurvePreset { name: name.into(), master, builtin: true, ..Default::default() };
|
||||
vec![
|
||||
s("Linear", Vec::new()),
|
||||
s("Medium Contrast", vec![[0.0, 0.0], [0.25, 0.2], [0.5, 0.5], [0.75, 0.8], [1.0, 1.0]]),
|
||||
s("Strong Contrast", vec![[0.0, 0.0], [0.25, 0.14], [0.5, 0.5], [0.75, 0.86], [1.0, 1.0]]),
|
||||
]
|
||||
}
|
||||
|
||||
/// Built-in presets, then the user's.
|
||||
pub fn all_presets(s: &Session) -> Vec<CurvePreset> {
|
||||
let mut v = builtin_presets();
|
||||
v.extend(s.curve_presets.iter().cloned());
|
||||
v
|
||||
}
|
||||
|
||||
/// Name of the preset whose point curves `c` carries (built-ins first), if any.
|
||||
pub fn matching_preset(s: &Session, c: &ToneCurve) -> Option<String> {
|
||||
all_presets(s).into_iter().find(|p| p.matches(c)).map(|p| p.name)
|
||||
}
|
||||
|
||||
fn is_builtin_name(name: &str) -> bool {
|
||||
builtin_presets().iter().any(|p| p.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Add or replace (same name, ignoring case) a user preset.
|
||||
fn upsert(s: &mut Session, p: CurvePreset) {
|
||||
match s.curve_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&p.name)) {
|
||||
Some(x) => *x = p,
|
||||
None => s.curve_presets.push(p),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_param(p: &Value, c: &str) -> Result<String> {
|
||||
str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `name`"))
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
let current = s.active().and_then(|id| s.develop_of(id)).and_then(|d| matching_preset(s, &d.curve));
|
||||
json!({"presets": all_presets(s), "current": current})
|
||||
}
|
||||
|
||||
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.applyPreset";
|
||||
let name = name_param(p, C)?;
|
||||
let preset =
|
||||
all_presets(s).into_iter().find(|x| x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(C, format!("no curve preset `{name}`")))?;
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
preset.apply(&mut d.curve);
|
||||
s.set_develop(id, d, &format!("Point Curve: {}", preset.name))?;
|
||||
Ok(json!({"name": preset.name}))
|
||||
}
|
||||
|
||||
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.savePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, format!("`{name}` is a built-in preset: choose another name")));
|
||||
}
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let preset = CurvePreset::from_curve(&name, &s.develop_of(id).unwrap_or_default().curve);
|
||||
if preset.is_linear() {
|
||||
return Err(bad(C, "the point curve is linear: shape it first"));
|
||||
}
|
||||
upsert(s, preset);
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.deletePreset";
|
||||
let name = name_param(p, C)?;
|
||||
if is_builtin_name(&name) {
|
||||
return Err(bad(C, "built-in presets can't be deleted"));
|
||||
}
|
||||
let n = s.curve_presets.len();
|
||||
s.curve_presets.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if n == s.curve_presets.len() {
|
||||
return Err(bad(C, format!("no curve preset `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// Presets in a curve-preset file: `{format, version, presets: [...]}`, a bare array, or one preset.
|
||||
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<CurvePreset>, String> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let items = match &v {
|
||||
Value::Object(o) if o.contains_key("presets") => o["presets"].clone(),
|
||||
Value::Array(_) => v.clone(),
|
||||
Value::Object(_) => Value::Array(vec![v.clone()]),
|
||||
_ => return Err("not a curve preset file".into()),
|
||||
};
|
||||
let list: Vec<CurvePreset> = serde_json::from_value(items).map_err(|e| format!("not a curve preset file: {e}"))?;
|
||||
let list: Vec<CurvePreset> = list.into_iter().filter(|p| !p.name.trim().is_empty()).collect();
|
||||
if list.is_empty() {
|
||||
return Err("no curve presets in the file".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// The JSON of a curve-preset file holding `presets`.
|
||||
pub fn to_file(presets: &[CurvePreset]) -> String {
|
||||
let presets: Vec<CurvePreset> = presets.iter().map(|p| CurvePreset { builtin: false, ..p.clone() }).collect();
|
||||
serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 1, "presets": presets})).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn import_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.importPresets";
|
||||
let mut paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
if let Some(x) = str_param(p, "path") {
|
||||
paths.push(x.to_string());
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Err(bad(C, "missing `path` / `paths`"));
|
||||
}
|
||||
let (mut imported, mut failed) = (Vec::new(), Vec::new());
|
||||
for f in &paths {
|
||||
match std::fs::read(f).map_err(|e| e.to_string()).and_then(|b| parse_file(&b)) {
|
||||
Ok(list) => {
|
||||
for mut preset in list {
|
||||
preset.name = preset.name.trim().to_string();
|
||||
preset.builtin = false;
|
||||
if is_builtin_name(&preset.name) {
|
||||
preset.name = format!("{} (imported)", preset.name);
|
||||
}
|
||||
imported.push(preset.name.clone());
|
||||
upsert(s, preset);
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(json!([f, e])),
|
||||
}
|
||||
}
|
||||
if !imported.is_empty() {
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "curve.exportPresets";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let mut path = std::path::PathBuf::from(path);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(CURVE_PRESET_EXT);
|
||||
}
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
let chosen: Vec<CurvePreset> = match &names {
|
||||
Some(n) => all_presets(s).into_iter().filter(|x| n.iter().any(|m| m.eq_ignore_ascii_case(&x.name))).collect(),
|
||||
None => s.curve_presets.clone(),
|
||||
};
|
||||
if chosen.is_empty() {
|
||||
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,9 @@ pub struct Session {
|
|||
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
|
||||
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
|
||||
pub filter_presets: Vec<cmd::filters::FilterPreset>,
|
||||
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
|
||||
/// persisted in prefs.json.
|
||||
pub curve_presets: Vec<cmd::curves::CurvePreset>,
|
||||
/// Saved colour-label name sets.
|
||||
pub label_sets: Vec<cmd::manage::LabelSet>,
|
||||
/// Look-alike signatures by content key (Find Similar).
|
||||
|
|
@ -195,6 +198,7 @@ impl Session {
|
|||
export_presets: Vec::new(),
|
||||
metadata_presets: Vec::new(),
|
||||
filter_presets: Vec::new(),
|
||||
curve_presets: Vec::new(),
|
||||
label_sets: Vec::new(),
|
||||
signatures: Default::default(),
|
||||
lut_profiles: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -112,6 +112,8 @@ struct PrefsFile {
|
|||
metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>,
|
||||
/// Filter presets.
|
||||
filter_presets: Vec<crate::cmd::filters::FilterPreset>,
|
||||
/// Point-curve presets.
|
||||
curve_presets: Vec<crate::cmd::curves::CurvePreset>,
|
||||
/// Colour-label name sets.
|
||||
label_sets: Vec<crate::cmd::manage::LabelSet>,
|
||||
/// Imported LUT profiles.
|
||||
|
|
@ -189,6 +191,7 @@ impl Session {
|
|||
self.export_presets = prefs.export_presets;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
self.filter_presets = prefs.filter_presets;
|
||||
self.curve_presets = prefs.curve_presets;
|
||||
self.label_sets = prefs.label_sets;
|
||||
self.lut_profiles = prefs.lut_profiles;
|
||||
crate::cmd::lut_profiles::register_all(self);
|
||||
|
|
@ -309,6 +312,7 @@ impl Session {
|
|||
export_presets: self.export_presets.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
filter_presets: self.filter_presets.clone(),
|
||||
curve_presets: self.curve_presets.clone(),
|
||||
label_sets: self.label_sets.clone(),
|
||||
lut_profiles: self.lut_profiles.clone(),
|
||||
keyword_sets: self.keyword_sets.clone(),
|
||||
|
|
|
|||
|
|
@ -202,3 +202,62 @@ fn metadata_presets_apply_to_photos_and_imports() {
|
|||
assert!(s.import_defaults.metadata_preset.is_none(), "deleting the import preset clears it");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_presets_save_apply_export_import_and_persist() {
|
||||
let lib = temp_dir("curves");
|
||||
let file = lib.join("out").join("looks");
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, true).unwrap();
|
||||
let r = s.execute("curve.presets", &json!({})).unwrap();
|
||||
let names: Vec<&str> = r["presets"].as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
assert_eq!(names, ["Linear", "Medium Contrast", "Strong Contrast"]);
|
||||
assert_eq!(r["current"], "Linear", "a fresh photo has linear point curves");
|
||||
// built-in: an S-curve on the master channel, parametric untouched
|
||||
s.execute("develop.set", &json!({"control": "curve.shadows", "value": 20})).unwrap();
|
||||
s.execute("curve.applyPreset", &json!({"name": "strong contrast"})).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
assert_eq!(d.curve.master.len(), 5);
|
||||
assert!(d.curve.master[1].y < 0.25 && d.curve.master[3].y > 0.75, "an S-curve");
|
||||
assert_eq!(d.curve.shadows, 20.0);
|
||||
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Strong Contrast");
|
||||
// save a custom one (with a red channel)
|
||||
s.execute("develop.curve", &json!({"channel": "red", "points": [[0.0, 0.1], [1.0, 0.9]]})).unwrap();
|
||||
assert!(s.execute("curve.savePreset", &json!({"name": "Linear"})).is_err(), "built-in names are reserved");
|
||||
s.execute("curve.savePreset", &json!({"name": "Faded Red"})).unwrap();
|
||||
assert_eq!(s.execute("curve.presets", &json!({})).unwrap()["current"], "Faded Red");
|
||||
// Linear resets every point channel
|
||||
s.execute("curve.applyPreset", &json!({"name": "Linear"})).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
assert!(d.curve.master.is_empty() && d.curve.red.is_empty());
|
||||
s.execute("curve.applyPreset", &json!({"name": "Faded Red"})).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().curve.red.len(), 2);
|
||||
// export (extension added), delete, re-import
|
||||
let r = s.execute("curve.exportPresets", &json!({"path": file.to_string_lossy()})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
let path = r["path"].as_str().unwrap().to_string();
|
||||
assert!(path.ends_with(".lccurve"));
|
||||
assert!(s.execute("curve.deletePreset", &json!({"name": "Medium Contrast"})).is_err());
|
||||
s.execute("curve.deletePreset", &json!({"name": "faded red"})).unwrap();
|
||||
assert!(s.curve_presets.is_empty());
|
||||
let r = s.execute("curve.importPresets", &json!({"path": path})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Faded Red"]));
|
||||
assert_eq!(s.curve_presets.len(), 1);
|
||||
// a file carrying a built-in name doesn't shadow it; junk fails cleanly
|
||||
let other = lib.join("one.lccurve");
|
||||
std::fs::write(&other, r#"{"name": "Linear", "master": [[0, 0.05], [1, 1]]}"#).unwrap();
|
||||
let junk = lib.join("junk.lccurve");
|
||||
std::fs::write(&junk, "not json").unwrap();
|
||||
let r = s.execute("curve.importPresets", &json!({"paths": [other.to_string_lossy(), junk.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["Linear (imported)"]));
|
||||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
// saved with the library preferences
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let names: Vec<String> = s.curve_presets.iter().map(|p| p.name.clone()).collect();
|
||||
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ pub struct Services {
|
|||
pub pick_preset_files: Option<PickFiles>,
|
||||
/// Save dialog for an exported `.lcpreset` file.
|
||||
pub save_preset_file: Option<SaveFile>,
|
||||
/// Open dialog for point-curve preset files (`.lccurve`).
|
||||
pub pick_curve_preset_files: Option<PickFiles>,
|
||||
/// Save dialog for an exported `.lccurve` file.
|
||||
pub save_curve_preset_file: Option<SaveFile>,
|
||||
pub write: Option<WriteFn>,
|
||||
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
|
||||
pub write_shared: Option<SharedWrite>,
|
||||
|
|
|
|||
|
|
@ -116,6 +116,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
|||
("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"),
|
||||
("file.importPresets", "Import Profiles & Presets…", None, "File"),
|
||||
("file.exportPresets", "Export Presets…", None, "File"),
|
||||
// Edit panel ▸ Curve ▸ Point Curve dropdown
|
||||
("file.importCurvePresets", "Import Point Curve Presets…", None, ""),
|
||||
("file.exportCurvePresets", "Export Point Curve Presets…", None, ""),
|
||||
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
|
||||
("app.openLibrary", "Open Library…", None, "File"),
|
||||
("app.about", "About LightCraft", None, "Help"),
|
||||
|
|
@ -1005,6 +1008,48 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
|||
}
|
||||
return Some(r);
|
||||
}
|
||||
"file.importCurvePresets" => {
|
||||
let paths: Vec<String> = match p.get("paths").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
None => match app.services.pick_curve_preset_files.as_mut() {
|
||||
Some(f) => f(),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
};
|
||||
if paths.is_empty() {
|
||||
return Some(Ok(Value::Null));
|
||||
}
|
||||
let r = app.session.execute("curve.importPresets", &json!({"paths": paths})).map_err(|e| e.to_string());
|
||||
if let Ok(v) = &r {
|
||||
let n = v["imported"].as_array().map_or(0, Vec::len);
|
||||
let failed = v["failed"].as_array().map_or(0, Vec::len);
|
||||
let mut msg = format!("Imported {n} point curve preset{}", if n == 1 { "" } else { "s" });
|
||||
if failed > 0 {
|
||||
msg += &format!(", {failed} file{} not readable", if failed == 1 { "" } else { "s" });
|
||||
}
|
||||
app.toast(&ctx, msg);
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"file.exportCurvePresets" => {
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => Some(x.to_string()),
|
||||
None => match app.services.save_curve_preset_file.as_mut() {
|
||||
Some(f) => f("Point Curves.lccurve"),
|
||||
None => return Some(Err("no file dialog on this platform".into())),
|
||||
},
|
||||
};
|
||||
let Some(path) = path else { return Some(Ok(Value::Null)) };
|
||||
let mut params = json!({"path": path});
|
||||
if let Some(n) = p.get("names") {
|
||||
params["names"] = n.clone();
|
||||
}
|
||||
let r = app.session.execute("curve.exportPresets", ¶ms).map_err(|e| e.to_string());
|
||||
if let Ok(v) = &r {
|
||||
app.toast(&ctx, format!("Exported {} point curve preset{}", v["count"], if v["count"] == 1 { "" } else { "s" }));
|
||||
}
|
||||
return Some(r);
|
||||
}
|
||||
"app.export" => crate::control::export_active(app, p),
|
||||
"app.showInFinder" => show_in_finder(app),
|
||||
"app.discord" | "app.website" | "app.github" | "app.artcraft" | "app.help" | "app.feedback" => {
|
||||
|
|
@ -1037,6 +1082,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
|||
.is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. }))
|
||||
}
|
||||
"file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin),
|
||||
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some(),
|
||||
|
|
|
|||
|
|
@ -790,10 +790,60 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev
|
|||
let _ = id;
|
||||
}
|
||||
|
||||
/// The row under the curve graph: reset every curve.
|
||||
fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, _d: &DevelopSettings) {
|
||||
/// The row under the curve graph: point-curve presets and reset every curve.
|
||||
fn curve_footer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
use lightcraft_engine::cmd::curves::{all_presets, matching_preset};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new("Point Curve").font(t.font(12.0)).color(t.text_label));
|
||||
let current = matching_preset(&app.session, &d.curve);
|
||||
let r = crate::widgets::dropdown(ui, "curvePreset", current.as_deref().unwrap_or("Custom"), t.font(12.0), t.text);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
let presets = all_presets(&app.session);
|
||||
let mut user_seen = false;
|
||||
for p in &presets {
|
||||
if !p.builtin && !user_seen {
|
||||
user_seen = true;
|
||||
ui.separator();
|
||||
}
|
||||
let r = ui.selectable_label(current.as_deref() == Some(p.name.as_str()), &p.name);
|
||||
register(ui.ctx(), format!("curvePreset:{}", p.name), r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("curve.applyPreset", json!({"name": p.name}));
|
||||
ui.close();
|
||||
}
|
||||
if !p.builtin {
|
||||
r.context_menu(|ui| {
|
||||
if ui.button("Delete Preset").clicked() {
|
||||
let _ = app.run("curve.deletePreset", json!({"name": p.name}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let shaped = current.as_deref() != Some("Linear");
|
||||
let r = ui.add_enabled(shaped, egui::Button::new("Save Point Curve…"));
|
||||
register(ui.ctx(), "curvePresetMenu:save", r.rect);
|
||||
if r.clicked() {
|
||||
crate::panels::dialogs::prompt(app, "Save Point Curve Preset", "Preset name", "", "curve.savePreset", json!({}), "name");
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(app.services.pick_curve_preset_files.is_some(), egui::Button::new("Import Presets…")).clicked() {
|
||||
let _ = app.run("file.importCurvePresets", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
let can_export = app.services.save_curve_preset_file.is_some() && !app.session.curve_presets.is_empty();
|
||||
if ui.add_enabled(can_export, egui::Button::new("Export Presets…")).clicked() {
|
||||
let _ = app.run("file.exportCurvePresets", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if presets.iter().any(|p| !p.builtin) {
|
||||
ui.label(RichText::new("Right-click a preset to delete it").font(t.font(11.0)).color(t.text_dim));
|
||||
}
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let r = text_button(ui, "curveReset", "Reset", false)
|
||||
.on_hover_text("Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)");
|
||||
|
|
|
|||
|
|
@ -103,6 +103,40 @@ fn reset_button_resets_every_curve() {
|
|||
assert_eq!(c, lightcraft_develop::ToneCurve::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_curve_preset_dropdown_applies_and_saves() {
|
||||
let mut h = curve_open("master");
|
||||
click_widget(&mut h, "dropdown:curvePreset", 1);
|
||||
click_widget(&mut h, "curvePreset:Medium Contrast", 1);
|
||||
let c = develop(&h).curve;
|
||||
assert_eq!(c.master.len(), 5, "the preset's S-curve: {:?}", c.master);
|
||||
assert!(c.master[1].y < 0.25 && c.master[3].y > 0.75);
|
||||
// shape it further, then save it from the dropdown (name prompt)
|
||||
exec(&mut h, "develop.curve", json!({"channel": "blue", "points": [[0.0, 0.08], [1.0, 1.0]]}));
|
||||
h.step();
|
||||
click_widget(&mut h, "dropdown:curvePreset", 1);
|
||||
click_widget(&mut h, "curvePresetMenu:save", 1);
|
||||
let mut dlg = h.app.ui.dialog.take().expect("a name prompt opens");
|
||||
match &mut dlg {
|
||||
crate::state::Dialog::TextPrompt { command, value, .. } => {
|
||||
assert_eq!(command, "curve.savePreset");
|
||||
*value = "Cool Shadows".into();
|
||||
}
|
||||
_ => panic!("a name prompt opens"),
|
||||
}
|
||||
let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg);
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
assert_eq!(h.app.session.curve_presets.len(), 1);
|
||||
// Linear from the dropdown flattens every channel
|
||||
click_widget(&mut h, "dropdown:curvePreset", 1);
|
||||
click_widget(&mut h, "curvePreset:Linear", 1);
|
||||
let c = develop(&h).curve;
|
||||
assert!(c.master.is_empty() && c.blue.is_empty());
|
||||
click_widget(&mut h, "dropdown:curvePreset", 1);
|
||||
click_widget(&mut h, "curvePreset:Cool Shadows", 1);
|
||||
assert_eq!(develop(&h).curve.blue.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_point_past_its_neighbour_is_clamped() {
|
||||
let mut h = curve_open("red");
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
|
|||
| LR-EDIT-LIGHT-WHITES | Whites | P0 | ✅ | `ctl:light.whites` | |
|
||||
| LR-EDIT-LIGHT-BLACKS | Blacks | P0 | ✅ | `ctl:light.blacks` | |
|
||||
| LR-EDIT-LIGHT-CURVE-PARAM | Parametric curve | P0 | ✅ | `ctl:curve.highlights`, `ctl:curve.lights`, `ctl:curve.darks`, `ctl:curve.shadows`, `ctl:curve.split*` | |
|
||||
| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); no curve presets (linear / medium / strong) |
|
||||
| LR-EDIT-LIGHT-CURVE-POINT | Point curve | P0 | ✅ | `cmd:develop.curve`, `cmd:curve.reset`, `cmd:curve.presets`, `cmd:curve.applyPreset`, `cmd:curve.savePreset`, `cmd:curve.deletePreset`, `cmd:curve.importPresets`, `cmd:curve.exportPresets`, `cmd:file.importCurvePresets`, `cmd:file.exportCurvePresets`, `crates/engine/src/cmd/curves.rs`, `crates/ui-egui/src/panels/edit.rs` | click to add a point; drag a point in both axes (between its neighbours, input / output readout, one undo step per drag); drag empty space to add and drag; double-click removes; reset: double-click a channel selector (that channel), the Reset button under the graph (every curve incl. parametric) or right-click the graph (channel / all); Point Curve preset dropdown: own Linear / Medium Contrast / Strong Contrast, user presets (save the current point curves, delete by right-click; saved with the library), import / export as `.lccurve` JSON |
|
||||
| LR-EDIT-LIGHT-CURVE-RGB | Per-channel curves | P0 | ✅ | `cmd:develop.curve` (`channel`) | |
|
||||
| LR-EDIT-LIGHT-CURVE-REFINESAT | Curve saturation compensation | P1 | ✅ | `ctl:curve.refineSaturation` | |
|
||||
| LR-EDIT-LIGHT-CURVE-TAT | Drag-on-image curve adjust | P1 | ✅ | `cmd:develop.targeted` (`target: curve`) | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue