Files
lightcraft/crates/engine/src/cmd/export.rs
T
wstockandClaude Fable 5.1 88d9b7196d Issues #181, #183: app.export refuses unknown params and bad values; watermark keys documented and range-checked
`ExportOptions::from_json` reads what it recognises and defaults the rest, so
`longEdgee=400` exported at 3000 px, `longEdge=banana` at full size, and a
`watermark` of `5` or `{"text":"x","bogus":1}` passed with `ok:true`, while
`size: 3` was silently clamped to 0.5 of the short edge (#181, #183).

`from_json` stays lenient for stored prefs and dialog prefill. New
`ExportOptions::validate(cmd, params)` / `from_params(params)` check every key
against the option and target lists (unknown ones name the nearest known key),
every value's kind and range, and a `watermark` object's keys, kinds and ranges
(`size` 0.005..0.5 and `inset` 0..0.4 of the short edge, `imageWidth` 0.01..1 of
the width, `opacity` 0..1, `color` [r,g,b]), returning
`invalid parameters for \`app.export\`: …` like the other commands. The three
places that run an export use it — the desktop `app.export`, the headless
backend behind `lightcraft-cli run` and the MCP `export` tool, and
`lightcraft-cli render --opt` — and `export.savePreset` validates a preset's
params when it is saved. Built-in presets, the Export dialog's `to_json`,
Export with Previous and `render --opt` all pass unchanged.

The `app.export` param string (`lightcraft-cli commands`, `engine.commands`),
the MCP `export` tool schema and docs/parity.md LR-EXP-WATERMARK now list the
watermark keys, their units and ranges.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-08 11:06:59 +00:00

116 lines
5.0 KiB
Rust

//! Export presets: built-in ones ([`crate::export::builtin_presets`]) and the user's, saved with the
//! library. `app.export {preset}` (desktop app, MCP, `lightcraft-cli run`) starts from a preset's
//! params; the call's own params override them (see [`Session::export_params`]).
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use crate::export::{ExportPreset, builtin_presets};
use crate::{Result, Session};
impl Session {
/// Built-in presets followed by the user's.
pub fn all_export_presets(&self) -> Vec<(ExportPreset, bool)> {
builtin_presets().into_iter().map(|p| (p, true)).chain(self.export_presets.iter().cloned().map(|p| (p, false))).collect()
}
/// `app.export` params with a named `preset` expanded: the preset's params, overridden by the
/// call's own (`preset` itself removed). Unknown preset names are an error.
pub fn export_params(&self, p: &Value) -> std::result::Result<Value, String> {
let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) };
let (preset, _) = self
.all_export_presets()
.into_iter()
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
let mut out = preset.params;
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
o.insert(k.clone(), v.clone());
}
}
Ok(out)
}
}
fn list(s: &Session) -> Value {
Value::Array(s.all_export_presets().into_iter().map(|(p, builtin)| json!({"name": p.name, "builtin": builtin, "params": p.params})).collect())
}
/// Params that describe a destination or a selection, not the export settings.
const NOT_SETTINGS: &[&str] = &["ids", "path", "dir", "preset"];
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "export.savePreset";
let name = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(ID, "missing `name`"))?;
if builtin_presets().iter().any(|b| b.name.eq_ignore_ascii_case(name)) {
return Err(bad(ID, format!("`{name}` is a built-in preset; choose another name")));
}
let mut params = match p.get("params") {
Some(v @ Value::Object(_)) => v.clone(),
Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")),
None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?,
};
if let Some(o) = params.as_object_mut() {
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
}
// a preset that app.export would refuse is refused here, where the typo is on screen
crate::export::ExportOptions::validate(ID, &params)?;
let preset = ExportPreset { name: name.to_string(), params };
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
Some(x) => *x = preset,
None => s.export_presets.push(preset),
}
s.save_prefs()?;
Ok(list(s))
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "export.deletePreset";
let name = p.get("name").and_then(Value::as_str).ok_or_else(|| bad(ID, "missing `name`"))?;
let before = s.export_presets.len();
s.export_presets.retain(|x| !x.name.eq_ignore_ascii_case(name.trim()));
if s.export_presets.len() == before {
return Err(bad(ID, format!("no user export preset `{name}`")));
}
s.save_prefs()?;
Ok(list(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"export.presets",
"Export Presets",
[],
None,
"{} → [{name, builtin, params}] (use with app.export {preset: name, …overrides})",
always,
|s, _| Ok(list(s))
),
cmd!(
"export.savePreset",
"Save Export Preset",
[],
None,
"{name, params?: app.export params (default: the last export's)} — adds or replaces a user preset, saved with the library → presets",
always,
save
),
cmd!("export.deletePreset", "Delete Export Preset", [], None, "{name} — removes a user preset → presets", always, delete),
cmd!(
query "export.checkTarget",
"Check Output Path",
[],
None,
"{path} — fails when writing `path` would replace a photo's original (or its XMP sidecar) in the library; front ends call it before saving a render or screenshot to a user-given path → {path}",
always,
|s, p| {
let path = p.get("path").and_then(Value::as_str).ok_or_else(|| bad("export.checkTarget", "missing `path`"))?;
s.check_write_target(path).map_err(|e| bad("export.checkTarget", e))?;
Ok(json!({"path": path}))
}
),
]
}