M0.6: engine (Session, ~75 commands: library/photo/album/develop/crop/masks/spots/presets/versions/history/queries; undo via inverse ops; interactions; render jobs; demo library)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 11:33:29 -04:00
parent fe0ca43eed
commit 69e8aa6e18
15 changed files with 2591 additions and 2 deletions

View File

@ -33,6 +33,8 @@ pub struct Filter {
pub date: Option<String>,
pub keyword: Option<String>,
pub camera: Option<String>,
/// Import date prefix (Recently Added).
pub imported: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -128,6 +130,11 @@ impl Filter {
{
return false;
}
if let Some(d) = &self.imported
&& !p.imported.starts_with(d.as_str())
{
return false;
}
if let Some(k) = &self.keyword
&& !p.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(k))
{

View File

@ -2,11 +2,11 @@
//! default, step, display precision and track style. The UI builds panels from it, MCP exposes it as
//! a schema, and `get`/`set` address settings by id (e.g. `light.exposure`, `mixer.red.hue`).
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::settings::DevelopSettings;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Section {
Light,

22
crates/engine/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "lightcraft-engine"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "LightCraft engine: session, command registry, history, render jobs, view-models (L4)"
[lints]
workspace = true
[dependencies]
lightcraft-geom = { workspace = true }
lightcraft-color = { workspace = true }
lightcraft-raster = { workspace = true }
lightcraft-develop = { workspace = true }
lightcraft-scenes = { workspace = true }
lightcraft-pipeline = { workspace = true }
lightcraft-catalog = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }

View File

@ -0,0 +1,529 @@
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
use lightcraft_catalog::{Op, PhotoId, Version};
use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, WbMode, controls};
use lightcraft_geom::{CropGeometry, Point, Rect, crop_fit_angle};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param};
use crate::{Result, Session, media::SourceLevel};
fn active(s: &Session, c: &str) -> Result<PhotoId> {
s.active().ok_or_else(|| bad(c, "no active photo"))
}
/// Modify the active photo's settings with `f` and commit (or preview during an interaction).
fn edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut DevelopSettings) -> Result<()>) -> Result<Value> {
let id = active(s, c)?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
f(&mut d)?;
s.set_develop(id, d, label)?;
ok()
}
fn section_param(p: &Value, c: &str) -> Result<Section> {
let v = p.get("section").cloned().ok_or_else(|| bad(c, "missing `section`"))?;
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
}
fn groups_param(p: &Value) -> Option<Vec<SettingsGroup>> {
p.get("groups").and_then(|g| serde_json::from_value(g.clone()).ok())
}
fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
let p = s.catalog.photo(id);
let (w, h) = p.map(|p| (p.width.max(1) as f64, p.height.max(1) as f64)).unwrap_or((3.0, 2.0));
let o = p.map(|p| p.develop.orientation).unwrap_or_default();
if o.swaps_axes() { (h, w) } else { (w, h) }
}
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
d.crop.aspect = aspect;
let a = aspect.map(|(aw, ah)| {
// orientation of the crop follows the image (landscape image → landscape crop)
let (aw, ah) = (aw as f64, ah as f64);
if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw }
});
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, a);
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"develop.set",
"Set Develop Values",
[],
None,
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls",
has_active,
|s, p| {
let mut vals: Vec<(String, f64)> = Vec::new();
if let (Some(c), Some(v)) = (str_param(p, "control"), p.get("value").and_then(Value::as_f64)) {
vals.push((c.to_string(), v));
}
if let Some(o) = p.get("values").and_then(Value::as_object) {
for (k, v) in o {
vals.push((k.clone(), v.as_f64().ok_or_else(|| bad("develop.set", format!("`{k}` must be a number")))?));
}
}
if vals.is_empty() {
return Err(bad("develop.set", "give `control`+`value` or `values`"));
}
for (k, _) in &vals {
if controls::find(k).is_none() {
return Err(bad("develop.set", format!("unknown control `{k}`")));
}
}
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
let apply = |d: &mut DevelopSettings| {
for (k, v) in &vals {
controls::set(d, k, *v);
if k == "wb.temp" || k == "wb.tint" {
d.wb.mode = WbMode::Custom;
}
}
};
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| {
let mut d = (*d).clone();
apply(&mut d);
s.develop_op(id, d, &label)
})
.collect();
s.commit(&label, Op::Batch { ops })?;
return ok();
}
edit(s, "develop.set", &label, |d| {
apply(d);
Ok(())
})
}
),
cmd!("develop.adjust", "Nudge Develop Value", [], None, "{control, delta}", has_active, |s, p| {
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
let delta = f64_req(p, "delta", "develop.adjust")?;
edit(s, "develop.adjust", spec.label, |d| {
let v = controls::get(d, &c).unwrap_or(spec.default);
controls::set(d, &c, v + delta);
Ok(())
})
}),
cmd!("develop.merge", "Apply Settings JSON", [], None, "{settings: partial DevelopSettings JSON, label?}", has_active, |s, p| {
let partial = p.get("settings").cloned().ok_or_else(|| bad("develop.merge", "missing settings"))?;
let label = str_param(p, "label").unwrap_or("Edit").to_string();
edit(s, "develop.merge", &label, |d| {
*d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?;
Ok(())
})
}),
cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| {
let ids = s.targets(p);
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| {
let fresh = DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() };
s.develop_op(id, fresh, "Reset")
})
.collect();
s.commit("Reset", Op::Batch { ops })?;
ok()
}),
cmd!(
"develop.resetSection",
"Reset Section",
[],
None,
"{section: light|curve|color|mixer|grading|effects|vignette|grain|detail|optics|geometry}",
has_active,
|s, p| {
let sec = section_param(p, "develop.resetSection")?;
edit(s, "develop.resetSection", &format!("Reset {}", sec.label()), |d| {
d.reset_section(sec);
Ok(())
})
}
),
cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| {
let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string();
let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?;
edit(s, "develop.resetControl", spec.label, |d| {
controls::set(d, &c, spec.default);
Ok(())
})
}),
cmd!("develop.sectionEnabled", "Toggle Section", [], None, "{section: string, enabled: bool}", has_active, |s, p| {
let sec = str_param(p, "section").ok_or_else(|| bad("develop.sectionEnabled", "missing section"))?.to_string();
let on = bool_or(p, "enabled", true);
edit(s, "develop.sectionEnabled", "Toggle Section", |d| {
d.set_section_enabled(&sec, on);
Ok(())
})
}),
cmd!("develop.auto", "Auto Settings", ["Photo"], Some("Shift+A"), "{}", has_active, |s, _| {
let id = active(s, "develop.auto")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.auto", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let d = s.develop_of(id).unwrap_or_default();
let a = lightcraft_pipeline::auto::auto_tone(&src, &info, &d);
edit(s, "develop.auto", "Auto", |d| {
d.light.exposure = a.exposure;
d.light.contrast = a.contrast;
d.light.highlights = a.highlights;
d.light.shadows = a.shadows;
d.light.whites = a.whites;
d.light.blacks = a.blacks;
d.color.vibrance = a.vibrance;
d.color.saturation = a.saturation;
Ok(())
})?;
Ok(serde_json::to_value(a).unwrap_or_default())
}),
cmd!(
"develop.wb",
"White Balance",
[],
None,
"{mode: asShot|auto|daylight|cloudy|shade|tungsten|fluorescent|flash|custom, temp?, tint?}",
has_active,
|s, p| {
let mode: WbMode =
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("custom"))).map_err(|e| bad("develop.wb", e.to_string()))?;
let id = active(s, "develop.wb")?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let (mut t, mut tint) = match mode {
WbMode::AsShot => (info.as_shot_temp, info.as_shot_tint),
WbMode::Auto => {
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.wb", e))?;
lightcraft_pipeline::auto::auto_wb(&src, &info)
}
m => m
.preset()
.map(|(t, ti)| {
// presets are relative to daylight for rendered files
if info.raw { (t, ti) } else { (6500.0 * t / 5500.0, ti) }
})
.unwrap_or((info.as_shot_temp, info.as_shot_tint)),
};
t = f64_or(p, "temp", t);
tint = f64_or(p, "tint", tint);
// The resolved temperature/tint is stored, so rendering never depends on the mode; the
// mode is kept for the UI's WB dropdown.
edit(s, "develop.wb", "White Balance", |d| {
d.wb.mode = mode;
d.wb.temp = t.clamp(2000.0, 50000.0);
d.wb.tint = tint.clamp(-150.0, 150.0);
Ok(())
})?;
Ok(json!({"temp": t, "tint": tint}))
}
),
cmd!("develop.wbPick", "White Balance Selector", [], Some("W"), "{x, y} normalized image coords of a neutral point", has_active, |s, p| {
let (x, y) = (f64_req(p, "x", "develop.wbPick")?, f64_req(p, "y", "develop.wbPick")?);
let id = active(s, "develop.wbPick")?;
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.wbPick", e))?;
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
let d = s.develop_of(id).unwrap_or_default();
// Sample a small neighbourhood in the oriented source.
let o = src.oriented(d.orientation);
let (cx, cy) = ((x.clamp(0.0, 1.0) * o.width as f64) as isize, (y.clamp(0.0, 1.0) * o.height as f64) as isize);
let mut acc = [0.0f32; 3];
for dy in -2..=2 {
for dx in -2..=2 {
let c = o.get_clamped(cx + dx, cy + dy);
for i in 0..3 {
acc[i] += c[i] / 25.0;
}
}
}
let patch = lightcraft_raster::Rgb32f::filled(4, 4, acc);
let (t, tint) = lightcraft_pipeline::auto::auto_wb(&patch, &info);
edit(s, "develop.wbPick", "White Balance", |d| {
d.wb.mode = WbMode::Custom;
d.wb.temp = t;
d.wb.tint = tint;
Ok(())
})?;
Ok(json!({"temp": t, "tint": tint}))
}),
cmd!("develop.treatment", "Black & White", ["Photo"], Some("V"), "{bw?: bool} (toggles when omitted)", has_active, |s, p| {
edit(s, "develop.treatment", "Treatment", |d| {
let bw = p.get("bw").and_then(Value::as_bool).unwrap_or(d.treatment != Treatment::Bw);
d.treatment = if bw { Treatment::Bw } else { Treatment::Color };
Ok(())
})
}),
cmd!("develop.profile", "Set Profile", [], None, "{id: profile id (see profiles.list), amount?: 0..200}", has_active, |s, p| {
let id = str_param(p, "id").ok_or_else(|| bad("develop.profile", "missing id"))?.to_string();
if !crate::presets::PROFILES.iter().any(|x| x.id == id) {
return Err(bad("develop.profile", format!("unknown profile `{id}`")));
}
edit(s, "develop.profile", "Profile", |d| {
d.profile.id = id;
d.profile.amount = f64_or(p, "amount", d.profile.amount).clamp(0.0, 200.0);
Ok(())
})
}),
cmd!("develop.curve", "Set Point Curve", [], None, "{channel: master|red|green|blue, points: [[x,y],...] in 0..1}", has_active, |s, p| {
let ch = str_param(p, "channel").unwrap_or("master").to_string();
let pts: Vec<Point> = p
.get("points")
.and_then(Value::as_array)
.ok_or_else(|| bad("develop.curve", "missing points"))?
.iter()
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?.clamp(0.0, 1.0), q.get(1)?.as_f64()?.clamp(0.0, 1.0))))
.collect();
edit(s, "develop.curve", "Tone Curve", |d| {
match ch.as_str() {
"master" | "rgb" => d.curve.master = pts,
"red" => d.curve.red = pts,
"green" => d.curve.green = pts,
"blue" => d.curve.blue = pts,
other => return Err(bad("develop.curve", format!("unknown channel `{other}`"))),
}
Ok(())
})
}),
// ---- crop & rotate
cmd!("crop.set", "Set Crop", [], None, "{rect?: [x0,y0,x1,y1] normalized in the straightened frame, angle?: degrees}", has_active, |s, p| {
let id = active(s, "crop.set")?;
let (w, h) = crop_dims(s, id);
edit(s, "crop.set", "Crop", |d| {
if let Some(a) = p.get("rect").and_then(Value::as_array) {
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
if v.len() != 4 {
return Err(bad("crop.set", "rect needs 4 numbers"));
}
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
}
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
d.crop.geometry.angle = a.clamp(-45.0, 45.0);
}
d.crop.geometry = d.crop.geometry.constrained(w, h);
Ok(())
})
}),
cmd!(
"crop.straighten",
"Straighten",
[],
None,
"{angle: degrees -45..45} — keeps the largest crop of the current aspect",
has_active,
|s, p| {
let id = active(s, "crop.straighten")?;
let (w, h) = crop_dims(s, id);
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
edit(s, "crop.straighten", "Straighten", |d| {
let r = d.crop.geometry.rect_px(w, h);
let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height()));
// keep the current crop size if it still fits, else shrink
let cand = CropGeometry { rect: d.crop.geometry.rect, angle };
d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit };
Ok(())
})
}
),
cmd!(
"crop.aspect",
"Crop Aspect",
[],
None,
"{aspect: \"original\"|\"free\"|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
has_active,
|s, p| {
let id = active(s, "crop.aspect")?;
let (w, h) = crop_dims(s, id);
let aspect = match p.get("aspect") {
Some(Value::String(a)) if a == "free" => None,
Some(Value::String(a)) if a == "original" => Some(((w * 100.0) as u32, (h * 100.0) as u32)),
Some(Value::String(a)) => {
let (x, y) = a.split_once('x').ok_or_else(|| bad("crop.aspect", "use WxH"))?;
let (x, y): (f64, f64) =
(x.parse().map_err(|_| bad("crop.aspect", "bad number"))?, y.parse().map_err(|_| bad("crop.aspect", "bad number"))?);
Some(((x * 100.0) as u32, (y * 100.0) as u32))
}
Some(Value::Array(a)) if a.len() == 2 => {
Some(((a[0].as_f64().unwrap_or(1.0) * 100.0) as u32, (a[1].as_f64().unwrap_or(1.0) * 100.0) as u32))
}
_ => return Err(bad("crop.aspect", "missing aspect")),
};
edit(s, "crop.aspect", "Crop Aspect", |d| {
if aspect.is_none() {
d.crop.aspect = None;
} else {
set_aspect(d, w, h, aspect);
}
Ok(())
})
}
),
cmd!("crop.rotateAspect", "Rotate Crop Aspect", [], Some("X"), "{}", has_active, |s, _| {
let id = active(s, "crop.rotateAspect")?;
let (w, h) = crop_dims(s, id);
edit(s, "crop.rotateAspect", "Rotate Crop", |d| {
let r = d.crop.geometry.rect_px(w, h);
let a = r.height() / r.width();
d.crop.aspect = d.crop.aspect.map(|(x, y)| (y, x));
let angle = d.crop.geometry.angle;
d.crop.geometry = crop_fit_angle(w, h, angle, Some(a));
Ok(())
})
}),
cmd!("crop.reset", "Reset Crop", [], Some("Cmd+Alt+R"), "{}", has_active, |s, _| {
edit(s, "crop.reset", "Reset Crop", |d| {
d.crop = Default::default();
Ok(())
})
}),
// ---- copy / paste / sync
cmd!(
"develop.copy",
"Copy Edit Settings",
["Edit"],
Some("Cmd+C"),
"{groups?: [settingsGroup]} (default: all but crop, masks, remove)",
has_active,
|s, p| {
let id = active(s, "develop.copy")?;
if let Some(g) = groups_param(p) {
s.copy_groups = g;
}
let d = s.develop_of(id).unwrap_or_default();
s.clipboard = Some(lightcraft_develop::extract_groups(&d, &s.copy_groups));
Ok(json!({"groups": s.copy_groups}))
}
),
cmd!("develop.paste", "Paste Edit Settings", ["Edit"], Some("Cmd+V"), "{ids?}", has_clipboard, |s, p| {
let clip = s.clipboard.clone().unwrap_or_default();
let ids = s.targets(p);
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &clip, 1.0), "Paste Settings"))
.collect::<Vec<_>>();
let n = ops.len();
s.commit("Paste Settings", Op::Batch { ops })?;
Ok(json!({"changed": n}))
}),
cmd!(
"develop.sync",
"Sync Settings",
[],
None,
"{groups?} — copy the active photo's settings to all selected photos",
has_selection,
|s, p| {
let id = active(s, "develop.sync")?;
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let partial = lightcraft_develop::extract_groups(&d, &groups);
let ops = s
.selection
.ids
.clone()
.into_iter()
.filter(|x| *x != id)
.filter_map(|x| s.develop_of(x).map(|d| (x, d)))
.filter_map(|(x, dd)| s.develop_op(x, lightcraft_develop::apply_partial(&dd, &partial, 1.0), "Sync Settings"))
.collect::<Vec<_>>();
let n = ops.len();
s.commit("Sync Settings", Op::Batch { ops })?;
Ok(json!({"changed": n}))
}
),
// ---- presets
cmd!("preset.apply", "Apply Preset", [], None, "{id: presetId, amount?: 0..200 (percent), ids?}", has_selection, |s, p| {
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?;
let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0;
let ids = s.targets(p);
let ops = ids
.iter()
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
.filter_map(|(id, d)| s.develop_op(id, preset.apply(&d, amount), &format!("Preset: {}", preset.name)))
.collect::<Vec<_>>();
s.commit(&format!("Preset: {}", preset.name), Op::Batch { ops })?;
ok()
}),
cmd!("preset.create", "Create Preset", ["Photo"], None, "{name, group?, groups?: [settingsGroup]}", has_active, |s, p| {
let id = active(s, "preset.create")?;
let name = str_param(p, "name").ok_or_else(|| bad("preset.create", "missing name"))?;
let group = str_param(p, "group").unwrap_or("User Presets");
let groups = groups_param(p).unwrap_or_else(SettingsGroup::default_copy);
let d = s.develop_of(id).unwrap_or_default();
let pid = format!("user.{}.{}", s.presets.len(), name.to_lowercase().replace(' ', "-"));
s.presets.push(Preset::from_settings(&pid, name, group, &d, &groups));
Ok(json!({"id": pid}))
}),
cmd!("preset.delete", "Delete Preset", [], None, "{id}", always, |s, p| {
let pid = str_param(p, "id").ok_or_else(|| bad("preset.delete", "missing id"))?;
let i = s.presets.iter().position(|x| x.id == pid).ok_or_else(|| bad("preset.delete", "unknown preset"))?;
if s.presets[i].builtin {
return Err(bad("preset.delete", "built-in presets can't be deleted"));
}
s.presets.remove(i);
ok()
}),
cmd!("preset.favorite", "Favorite Preset", [], None, "{id, favorite?: bool}", always, |s, p| {
let pid = str_param(p, "id").ok_or_else(|| bad("preset.favorite", "missing id"))?;
let pr = s.presets.iter_mut().find(|x| x.id == pid).ok_or_else(|| bad("preset.favorite", "unknown preset"))?;
pr.favorite = bool_or(p, "favorite", !pr.favorite);
ok()
}),
// ---- versions & history
cmd!("version.create", "Create Version", ["Photo"], Some("Cmd+Shift+S"), "{name?}", has_active, |s, p| {
let id = active(s, "version.create")?;
let ph = s.catalog.photo(id).cloned().ok_or_else(|| bad("version.create", "no photo"))?;
let mut versions = ph.versions.clone();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Version {}", versions.len() + 1));
versions.push(Version { name, created: (s.clock)(), settings: ph.develop.clone(), auto: false });
s.commit("Create Version", Op::SetVersions { id, versions })?;
ok()
}),
cmd!("version.restore", "Restore Version", [], None, "{index}", has_active, |s, p| {
let id = active(s, "version.restore")?;
let i = f64_req(p, "index", "version.restore")? as usize;
let v = s.catalog.photo(id).and_then(|ph| ph.versions.get(i).cloned()).ok_or_else(|| bad("version.restore", "no such version"))?;
s.set_develop(id, (*v.settings).clone(), &format!("Restore {}", v.name))?;
ok()
}),
cmd!("version.delete", "Delete Version", [], None, "{index}", has_active, |s, p| {
let id = active(s, "version.delete")?;
let i = f64_req(p, "index", "version.delete")? as usize;
let mut versions = s.catalog.photo(id).map(|ph| ph.versions.clone()).unwrap_or_default();
if i >= versions.len() {
return Err(bad("version.delete", "no such version"));
}
versions.remove(i);
s.commit("Delete Version", Op::SetVersions { id, versions })?;
ok()
}),
cmd!("history.restore", "Go to History Step", [], None, "{index}", has_active, |s, p| {
let id = active(s, "history.restore")?;
let i = f64_req(p, "index", "history.restore")? as usize;
let st = s.catalog.photo(id).and_then(|ph| ph.history.get(i).cloned()).ok_or_else(|| bad("history.restore", "no such step"))?;
s.set_develop(id, (*st.settings).clone(), &format!("History: {}", st.label))?;
ok()
}),
// ---- interactions (slider drags, brush strokes)
cmd!("develop.beginInteraction", "Begin Interaction", [], None, "{label}", has_active, |s, p| {
s.begin_interaction(str_param(p, "label").unwrap_or("Edit"))?;
ok()
}),
cmd!("develop.endInteraction", "End Interaction", [], None, "{}", always, |s, _| {
s.end_interaction()?;
ok()
}),
cmd!("develop.cancelInteraction", "Cancel Interaction", [], None, "{}", always, |s, _| {
s.cancel_interaction()?;
ok()
}),
]
}

View File

@ -0,0 +1,13 @@
use serde_json::json;
use super::{CommandSpec, can_redo, can_undo, cmd};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |s, _| {
s.end_interaction()?;
Ok(json!({"undone": s.undo_step()?}))
}),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |s, _| Ok(json!({"redone": s.redo_step()?}))),
]
}

View File

@ -0,0 +1,386 @@
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
//! metadata, albums, import.
use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, Op, PhotoId, Sort, SortKey};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`")))
}
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
}
/// Apply one op per target as a single undo step.
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
let targets = s.targets(p);
let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect();
let n = ops.len();
if n > 0 {
s.commit(label, Op::Batch { ops })?;
}
Ok(json!({"changed": n}))
}
fn advance_if(s: &mut Session, p: &Value) {
if bool_or(p, "advance", false) {
let _ = step(s, 1);
}
}
fn step(s: &mut Session, d: isize) -> Result<Value> {
let vis = s.visible_cloned();
if vis.is_empty() {
return ok();
}
let cur = s.selection.active.and_then(|a| vis.iter().position(|x| *x == a));
let next = match cur {
Some(i) => (i as isize + d).clamp(0, vis.len() as isize - 1) as usize,
None => 0,
};
s.end_interaction()?;
s.selection = Selection::single(vis[next]);
s.active_mask = None;
Ok(json!({"active": vis[next].0}))
}
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
let targets = s.targets(p);
let mut ops = Vec::new();
for id in targets {
if let Some(d) = s.develop_of(id) {
let mut d = (*d).clone();
d.orientation = d.orientation.rotated(cw);
ops.extend(s.develop_op(id, d, "Rotate"));
}
}
let n = ops.len();
s.commit(if cw { "Rotate Right" } else { "Rotate Left" }, Op::Batch { ops })?;
Ok(json!({"changed": n}))
}
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let targets = s.targets(p);
let mut ops = Vec::new();
for id in targets {
if let Some(d) = s.develop_of(id) {
let mut d = (*d).clone();
if horizontal {
d.crop.flip_h = !d.crop.flip_h;
} else {
d.crop.flip_v = !d.crop.flip_v;
}
ops.extend(s.develop_op(id, d, "Flip"));
}
}
let n = ops.len();
s.commit("Flip", Op::Batch { ops })?;
Ok(json!({"changed": n}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
// ---- view source / filter / sort
cmd!("library.source", "Show Source", [], None, "{kind: all|recentlyAdded|album|recentlyDeleted|picks, id?: albumId}", always, |s, p| {
let kind = str_param(p, "kind").unwrap_or("all");
s.source = match kind {
"all" => LibrarySource::All,
"recentlyAdded" => LibrarySource::RecentlyAdded,
"recentlyDeleted" => LibrarySource::RecentlyDeleted,
"picks" => LibrarySource::Picks,
"album" => {
let a = album_param(p, "id", "library.source")?;
if s.catalog.album(a).is_none_or(|a| a.folder) {
return Err(bad("library.source", "no such album"));
}
LibrarySource::Album(a)
}
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
};
let vis = s.visible_cloned();
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
}
Ok(json!({"count": vis.len()}))
}),
cmd!(
"library.filter",
"Filter",
[],
None,
"partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, edited?, date?, keyword?, camera?}",
always,
|s, p| {
let mut v = serde_json::to_value(&s.filter).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, p);
s.filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?;
Ok(json!({"count": s.visible().len()}))
}
),
cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {
s.filter = Default::default();
Ok(json!({"count": s.visible().len()}))
}),
cmd!(
"library.sort",
"Sort",
["View", "Sort"],
None,
"{key: captureDate|importDate|editDate|fileName|rating|fileSize, ascending?: bool}",
always,
|s, p| {
let key: SortKey = match p.get("key") {
Some(k) => serde_json::from_value(k.clone()).map_err(|e| bad("library.sort", e.to_string()))?,
None => s.sort.key,
};
s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending) };
ok()
}
),
// ---- selection
cmd!("library.select", "Select Photos", [], None, "{ids: [id], active?: id, mode?: replace|add|toggle|range}", always, |s, p| {
let ids = ids_param(p).unwrap_or_default();
let mode = str_param(p, "mode").unwrap_or("replace");
s.end_interaction()?;
match mode {
"replace" => {
s.selection =
Selection { ids: ids.clone(), active: p.get("active").and_then(Value::as_u64).map(PhotoId).or(ids.first().copied()) };
}
"add" => {
for id in ids {
if !s.selection.contains(id) {
s.selection.ids.push(id);
}
s.selection.active = Some(id);
}
}
"toggle" => ids.into_iter().for_each(|id| s.selection.toggle(id)),
"range" => {
let vis = s.visible_cloned();
if let Some(id) = ids.first() {
s.selection.extend_to(*id, &vis);
}
}
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
}
s.active_mask = None;
Ok(json!({"selected": s.selection.ids.len()}))
}),
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {
let vis = s.visible_cloned();
s.selection = Selection { active: s.selection.active.filter(|a| vis.contains(a)).or(vis.first().copied()), ids: vis };
Ok(json!({"selected": s.selection.ids.len()}))
}),
cmd!("library.selectNone", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| {
let a = s.selection.active;
s.selection = Selection { ids: a.into_iter().collect(), active: a };
ok()
}),
cmd!("library.next", "Next Photo", [], Some("Right"), "{}", always, |s, _| step(s, 1)),
cmd!("library.previous", "Previous Photo", [], Some("Left"), "{}", always, |s, _| step(s, -1)),
// ---- rating / flags / labels
cmd!("photo.rate", "Set Rating", ["Photo", "Set Rating"], None, "{rating: 0..5, ids?, advance?: bool}", has_selection, |s, p| {
let r = super::f64_req(p, "rating", "photo.rate")? as i64;
if !(0..=5).contains(&r) {
return Err(bad("photo.rate", "rating must be 0..5"));
}
let v = for_targets(s, p, "Set Rating", |id| Some(Op::SetRating { id, rating: r as u8 }))?;
advance_if(s, p);
Ok(v)
}),
cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| {
let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?;
let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?;
advance_if(s, p);
Ok(v)
}),
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
}),
cmd!("photo.reject", "Flag as Reject", ["Photo", "Set Flag"], Some("X"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Flag as Reject", |id| Some(Op::SetFlag { id, flag: Flag::Reject }))
}),
cmd!("photo.unflag", "Unflag", ["Photo", "Set Flag"], Some("U"), "{ids?}", has_selection, |s, p| {
for_targets(s, p, "Unflag", |id| Some(Op::SetFlag { id, flag: Flag::None }))
}),
cmd!(
"photo.label",
"Set Color Label",
["Photo", "Set Color Label"],
None,
"{label: red|yellow|green|blue|purple|none, ids?}",
has_selection,
|s, p| {
let l = match str_param(p, "label") {
None | Some("none") => None,
Some(x) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?),
};
for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))
}
),
// ---- orientation
cmd!("photo.rotateLeft", "Rotate Left", ["Photo"], Some("Cmd+["), "{ids?}", has_selection, |s, p| rotate(s, p, false)),
cmd!("photo.rotateRight", "Rotate Right", ["Photo"], Some("Cmd+]"), "{ids?}", has_selection, |s, p| rotate(s, p, true)),
cmd!("photo.flipHorizontal", "Flip Horizontal", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, true)),
cmd!("photo.flipVertical", "Flip Vertical", ["Photo"], None, "{ids?}", has_selection, |s, p| flip(s, p, false)),
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
let vis = s.visible_cloned();
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
Ok(v)
}),
cmd!("photo.restore", "Restore", [], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
id,
deleted: false
}))),
cmd!("photo.deletePermanently", "Delete Permanently", [], None, "{ids?}", has_selection, |s, p| {
let t = s.targets(p);
let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::<Vec<_>>();
s.commit("Delete Permanently", Op::Batch { ops })?;
s.selection = Selection::default();
Ok(json!({"deleted": t.len()}))
}),
// ---- metadata
cmd!(
"photo.setMeta",
"Edit Info",
[],
None,
"{ids?, title?, caption?, copyright?, creator?, location?, keywords?: [..], addKeywords?: [..], removeKeywords?: [..]}",
has_selection,
|s, p| {
let strs =
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
let targets = s.targets(p);
let mut ops = Vec::new();
for id in targets {
let Some(ph) = s.catalog.photo(id) else { continue };
let mut m = ph.meta.clone();
for (k, field) in [
("title", &mut m.title),
("caption", &mut m.caption),
("copyright", &mut m.copyright),
("creator", &mut m.creator),
("location", &mut m.location),
] {
if let Some(v) = str_param(p, k) {
*field = v.to_string();
}
}
if let Some(k) = strs("keywords") {
m.keywords = k;
}
for k in strs("addKeywords").unwrap_or_default() {
if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) {
m.keywords.push(k);
}
}
for k in strs("removeKeywords").unwrap_or_default() {
m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k));
}
ops.push(Op::SetMeta { id, meta: Box::new(m) });
}
let n = ops.len();
s.commit("Edit Info", Op::Batch { ops })?;
Ok(json!({"changed": n}))
}
),
// ---- albums
cmd!(
"album.create",
"New Album",
["File"],
Some("Cmd+Shift+N"),
"{name, parent?: folderId, folder?: bool, addSelected?: bool}",
always,
|s, p| {
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
if name.is_empty() {
return Err(bad("album.create", "empty name"));
}
let folder = bool_or(p, "folder", false);
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] };
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
s.commit(if folder { "New Folder" } else { "New Album" }, Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover } })?;
Ok(json!({"id": id.0}))
}
),
cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| {
let id = album_param(p, "id", "album.rename")?;
let name = str_param(p, "name").ok_or_else(|| bad("album.rename", "missing name"))?.to_string();
s.commit("Rename Album", Op::RenameAlbum { id, name })?;
ok()
}),
cmd!("album.delete", "Delete Album", [], None, "{id}", always, |s, p| {
let id = album_param(p, "id", "album.delete")?;
s.commit("Delete Album", Op::RemoveAlbum { id })?;
if s.source == LibrarySource::Album(id) {
s.source = LibrarySource::All;
}
ok()
}),
cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {
let id = album_param(p, "id", "album.move")?;
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
s.commit("Move Album", Op::MoveAlbum { id, parent })?;
ok()
}),
cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| {
let id = album_param(p, "id", "album.addPhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
let mut photos = al.photos.clone();
let before = photos.len();
for t in targets {
if !photos.contains(&t) {
photos.push(t);
}
}
let added = photos.len() - before;
let cover = al.cover.or(photos.first().copied());
s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?;
Ok(json!({"added": added}))
}),
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
let id = album_param(p, "id", "album.removePhotos")?;
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
ok()
}),
cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| {
let id = album_param(p, "id", "album.setCover")?;
let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active());
s.commit("Set Album Cover", Op::SetAlbumCover { id, cover: photo })?;
ok()
}),
// ---- import
cmd!("library.import", "Add Photos…", ["File"], Some("Cmd+Shift+I"), "{paths: [path], album?: albumId}", always, |s, p| {
let 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 paths.is_empty() {
return Err(bad("library.import", "no paths"));
}
let imported = crate::media::import_paths(s, &paths)?;
if let Some(a) = p.get("album").and_then(Value::as_u64) {
s.execute("album.addPhotos", &json!({"id": a, "ids": imported.iter().map(|i| i.0).collect::<Vec<_>>()}))?;
}
if let Some(f) = imported.first() {
s.selection = Selection::single(*f);
}
Ok(json!({"imported": imported.iter().map(|i| i.0).collect::<Vec<_>>()}))
}),
]
}

View File

@ -0,0 +1,325 @@
//! Masking and Remove (spot) commands on the active photo.
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, Spot, SpotMode};
use lightcraft_geom::Point;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, f64_or, has_active, ok, point, str_param};
use crate::{Result, Session};
fn shape_from(kind: &str, p: &Value, c: &str) -> Result<MaskShape> {
Ok(match kind {
"brush" => MaskShape::Brush { strokes: vec![] },
"linear" => {
MaskShape::Linear { start: point(p, "start").unwrap_or(Point::new(0.5, 0.25)), end: point(p, "end").unwrap_or(Point::new(0.5, 0.6)) }
}
"radial" => MaskShape::Radial {
center: point(p, "center").unwrap_or(Point::new(0.5, 0.5)),
rx: f64_or(p, "rx", 0.22),
ry: f64_or(p, "ry", 0.16),
angle: f64_or(p, "angle", 0.0),
feather: f64_or(p, "feather", 50.0),
invert: bool_or(p, "invert", false),
},
"sky" => MaskShape::Sky,
"subject" => MaskShape::Subject,
"background" => MaskShape::Background,
"luminanceRange" => MaskShape::LuminanceRange {
lo: f64_or(p, "lo", 0.6),
hi: f64_or(p, "hi", 1.0),
lo_feather: f64_or(p, "loFeather", 0.1),
hi_feather: f64_or(p, "hiFeather", 0.1),
},
"colorRange" => {
let samples: Vec<[f64; 3]> = p
.get("samples")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|s| Some([s.get(0)?.as_f64()?, s.get(1)?.as_f64()?, s.get(2)?.as_f64()?])).collect())
.unwrap_or_default();
MaskShape::ColorRange { samples, refine: f64_or(p, "refine", 50.0) }
}
other => return Err(bad(c, format!("unknown mask kind `{other}` (brush|linear|radial|sky|subject|background|luminanceRange|colorRange)"))),
})
}
fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mask>, &mut Option<u32>) -> Result<()>) -> Result<Value> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
let mut active = s.active_mask;
f(&mut d.masks, &mut active)?;
s.set_develop(id, d, label)?;
s.active_mask = active;
Ok(json!({"activeMask": active}))
}
fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
}
fn find(masks: &mut [Mask], id: u32, c: &str) -> Result<usize> {
masks.iter().position(|m| m.id == id).ok_or_else(|| bad(c, format!("no mask {id}")))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"mask.add",
"Create New Mask",
[],
None,
"{kind: brush|linear|radial|sky|subject|background|luminanceRange|colorRange, ...shape params (start/end, center/rx/ry/angle/feather, lo/hi…), name?}",
has_active,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("radial").to_string();
let shape = shape_from(&kind, p, "mask.add")?;
let name = str_param(p, "name").map(str::to_string);
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
masks_edit(s, "mask.add", "Add Mask", |masks, active| {
masks.push(Mask {
id: next,
name: name.unwrap_or_else(|| format!("Mask {next}")),
components: vec![MaskComponent { op: MaskOp::Add, invert: false, shape }],
..Default::default()
});
*active = Some(next);
Ok(())
})
}
),
cmd!(
"mask.addComponent",
"Add/Subtract/Intersect Mask",
[],
None,
"{id?: maskId, op: add|subtract|intersect, kind, ...shape params}",
has_active,
|s, p| {
let kind = str_param(p, "kind").unwrap_or("brush").to_string();
let shape = shape_from(&kind, p, "mask.addComponent")?;
let op: MaskOp =
serde_json::from_value(p.get("op").cloned().unwrap_or(json!("add"))).map_err(|e| bad("mask.addComponent", e.to_string()))?;
let active = s.active_mask;
let mid = mask_id(p, active, "mask.addComponent")?;
masks_edit(s, "mask.addComponent", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.addComponent")?;
masks[i].components.push(MaskComponent { op, invert: bool_or(p, "invert", false), shape });
Ok(())
})
}
),
cmd!(
"mask.brushStroke",
"Brush Stroke",
[],
None,
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool}",
has_active,
|s, p| {
let pts: Vec<Point> = p
.get("points")
.and_then(Value::as_array)
.ok_or_else(|| bad("mask.brushStroke", "missing points"))?
.iter()
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?)))
.collect();
let d = BrushStroke::default();
let stroke = BrushStroke {
points: pts,
size: f64_or(p, "size", d.size),
feather: f64_or(p, "feather", d.feather),
flow: f64_or(p, "flow", d.flow),
density: f64_or(p, "density", d.density),
erase: bool_or(p, "erase", false),
auto_mask: bool_or(p, "autoMask", false),
};
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
Some(i) => i,
None => {
masks.push(Mask { id: next, name: format!("Mask {next}"), ..Default::default() });
*active = Some(next);
masks.len() - 1
}
};
let m = &mut masks[i];
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
{
strokes.push(stroke);
} else {
m.components.push(MaskComponent { op: MaskOp::Add, invert: false, shape: MaskShape::Brush { strokes: vec![stroke] } });
}
Ok(())
})
}
),
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
let shape: MaskShape =
serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?;
let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0) as usize;
let mid = mask_id(p, s.active_mask, "mask.update")?;
masks_edit(s, "mask.update", "Edit Mask", |masks, _| {
let i = find(masks, mid, "mask.update")?;
let c = masks[i].components.get_mut(comp).ok_or_else(|| bad("mask.update", "no such component"))?;
c.shape = shape;
Ok(())
})
}),
cmd!(
"mask.adjust",
"Set Mask Adjustments",
[],
None,
"{id?, values: {exposure, contrast, highlights, shadows, whites, blacks, temp, tint, texture, clarity, dehaze, hue, saturation, sharpness, noise, moire, defringe, color_hue, color_sat, amount}}",
has_active,
|s, p| {
let vals = p.get("values").cloned().ok_or_else(|| bad("mask.adjust", "missing values"))?;
let mid = mask_id(p, s.active_mask, "mask.adjust")?;
masks_edit(s, "mask.adjust", "Mask Adjustment", |masks, _| {
let i = find(masks, mid, "mask.adjust")?;
let mut v = serde_json::to_value(masks[i].adjust).unwrap_or_default();
lightcraft_develop::presets::deep_merge(&mut v, &vals);
let a: LocalAdjustments = serde_json::from_value(v).map_err(|e| bad("mask.adjust", e.to_string()))?;
masks[i].adjust = clamp_local(a);
Ok(())
})
}
),
cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| {
s.active_mask = p.get("id").and_then(Value::as_u64).map(|v| v as u32);
ok()
}),
cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.delete")?;
masks_edit(s, "mask.delete", "Delete Mask", |masks, active| {
let i = find(masks, mid, "mask.delete")?;
masks.remove(i);
*active = masks.last().map(|m| m.id);
Ok(())
})
}),
cmd!("mask.deleteAll", "Delete All Masks", [], None, "{}", has_active, |s, _| {
masks_edit(s, "mask.deleteAll", "Delete All Masks", |masks, active| {
masks.clear();
*active = None;
Ok(())
})
}),
cmd!("mask.rename", "Rename Mask", [], None, "{id?, name}", has_active, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("mask.rename", "missing name"))?.to_string();
let mid = mask_id(p, s.active_mask, "mask.rename")?;
masks_edit(s, "mask.rename", "Rename Mask", |masks, _| {
let i = find(masks, mid, "mask.rename")?;
masks[i].name = name;
Ok(())
})
}),
cmd!("mask.invert", "Invert Mask", [], None, "{id?}", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.invert")?;
masks_edit(s, "mask.invert", "Invert Mask", |masks, _| {
let i = find(masks, mid, "mask.invert")?;
masks[i].invert = !masks[i].invert;
Ok(())
})
}),
cmd!("mask.visible", "Show/Hide Mask", [], None, "{id?, visible?: bool}", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.visible")?;
masks_edit(s, "mask.visible", "Toggle Mask", |masks, _| {
let i = find(masks, mid, "mask.visible")?;
masks[i].visible = bool_or(p, "visible", !masks[i].visible);
Ok(())
})
}),
cmd!("mask.duplicate", "Duplicate Mask", [], None, "{id?}", has_active, |s, p| {
let mid = mask_id(p, s.active_mask, "mask.duplicate")?;
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
masks_edit(s, "mask.duplicate", "Duplicate Mask", |masks, active| {
let i = find(masks, mid, "mask.duplicate")?;
let mut m = masks[i].clone();
m.id = next;
m.name = format!("{} copy", m.name);
masks.push(m);
*active = Some(next);
Ok(())
})
}),
// ---- Remove tool (spots)
cmd!(
"spot.add",
"Add Remove Spot",
[],
None,
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy]}",
has_active,
|s, p| {
let mode: SpotMode =
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("remove"))).map_err(|e| bad("spot.add", e.to_string()))?;
let pts: Vec<Point> = p
.get("points")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect())
.unwrap_or_default();
if pts.is_empty() {
return Err(bad("spot.add", "missing points"));
}
let d = Spot::default();
let spot = Spot {
mode,
points: pts,
size: f64_or(p, "size", d.size),
feather: f64_or(p, "feather", d.feather),
opacity: f64_or(p, "opacity", d.opacity),
source_offset: point(p, "source"),
};
let id = s.active().ok_or_else(|| bad("spot.add", "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
dd.spots.push(spot);
let n = dd.spots.len();
s.set_develop(id, dd, "Remove")?;
Ok(json!({"index": n - 1}))
}
),
cmd!("spot.delete", "Delete Spot", [], None, "{index}", has_active, |s, p| {
let i = super::f64_req(p, "index", "spot.delete")? as usize;
let id = s.active().ok_or_else(|| bad("spot.delete", "no active photo"))?;
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
if i >= dd.spots.len() {
return Err(bad("spot.delete", "no such spot"));
}
dd.spots.remove(i);
s.set_develop(id, dd, "Delete Spot")?;
ok()
}),
]
}
fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
c(&mut a.exposure, -4.0, 4.0);
for v in [
&mut a.temp,
&mut a.tint,
&mut a.contrast,
&mut a.highlights,
&mut a.shadows,
&mut a.whites,
&mut a.blacks,
&mut a.texture,
&mut a.clarity,
&mut a.dehaze,
&mut a.hue,
&mut a.saturation,
&mut a.sharpness,
&mut a.noise,
&mut a.moire,
&mut a.defringe,
] {
c(v, -100.0, 100.0);
}
c(&mut a.color_hue, 0.0, 360.0);
c(&mut a.color_sat, 0.0, 100.0);
c(&mut a.amount, 0.0, 200.0);
a
}

View File

@ -0,0 +1,135 @@
//! The command registry.
//!
//! Command ids group by area: `library.*` (view/selection), `photo.*` (rating, flags, rotate,
//! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`,
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
mod develop;
mod edit;
mod library;
mod masks;
mod query;
use serde::Serialize;
use serde_json::Value;
use crate::{EngineError, Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Menu placement (`["Photo", "Rotate"]`); empty = not in menus.
pub menu: &'static [&'static str],
/// Default shortcut (`Cmd+Shift+C`), mapped per platform by the UI.
pub shortcut: Option<&'static str>,
/// Human/agent-readable parameter description.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
/// Record in the journal (false for queries).
pub journal: bool,
}
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub menu: Vec<&'static str>,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
}
impl CommandSpec {
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
CommandInfo {
id: self.id,
label: self.label,
menu: self.menu.to_vec(),
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
}
}
}
// ---------- enablement predicates
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn has_active(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no photo selected".into())
}
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
if s.selection.ids.is_empty() && s.selection.active.is_none() { Err("no photos selected".into()) } else { Ok(()) }
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
if s.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) }
}
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
if s.redo.is_empty() { Err("nothing to redo".into()) } else { Ok(()) }
}
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
if s.clipboard.is_none() { Err("no settings copied".into()) } else { Ok(()) }
}
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
};
}
pub(crate) use cmd;
pub fn command_specs() -> &'static [CommandSpec] {
static SPECS: std::sync::OnceLock<Vec<CommandSpec>> = std::sync::OnceLock::new();
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(edit::specs());
v.extend(library::specs());
v.extend(develop::specs());
v.extend(masks::specs());
v.extend(query::specs());
v
})
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
// ---------- param helpers
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
pub(crate) fn f64_or(p: &Value, key: &str, d: f64) -> f64 {
p.get(key).and_then(Value::as_f64).unwrap_or(d)
}
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
p.get(key).and_then(Value::as_str)
}
pub(crate) fn bool_or(p: &Value, key: &str, d: bool) -> bool {
p.get(key).and_then(Value::as_bool).unwrap_or(d)
}
pub(crate) fn ok() -> Result<Value> {
Ok(Value::Null)
}
pub(crate) fn point(p: &Value, key: &str) -> Option<lightcraft_geom::Point> {
let a = p.get(key)?.as_array()?;
Some(lightcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}

View File

@ -0,0 +1,138 @@
//! Read-only queries (not journaled): catalog, photos, develop state, controls, presets, albums.
use lightcraft_catalog::{Album, Photo, PhotoId};
use lightcraft_develop::{CONTROLS, controls};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd, has_active};
use crate::Session;
pub fn photo_summary(p: &Photo) -> Value {
json!({
"id": p.id.0,
"fileName": p.file_name,
"format": p.format,
"kind": p.kind,
"width": p.width,
"height": p.height,
"captured": p.captured,
"rating": p.rating,
"flag": p.flag,
"label": p.label,
"edited": p.is_edited(),
"title": p.meta.title,
"keywords": p.meta.keywords,
"camera": p.meta.camera,
"deleted": p.deleted,
})
}
fn album_json(a: &Album, all: &[Album]) -> Value {
json!({
"id": a.id.0,
"name": a.name,
"folder": a.folder,
"count": a.photos.len(),
"cover": a.cover.map(|c| c.0),
"children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all)).collect::<Vec<_>>(),
})
}
fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(c, "no photo"))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(query "catalog.query", "Query Photos", [], None, "{filter?: Filter, sort?: Sort, offset?, limit?} — omit filter to list the current view", always, |s, p| {
let ids = if p.get("filter").is_some() || p.get("sort").is_some() {
let f = p.get("filter").map(|f| serde_json::from_value(f.clone())).transpose().map_err(|e| bad("catalog.query", e.to_string()))?.unwrap_or_default();
let so = p.get("sort").map(|f| serde_json::from_value(f.clone())).transpose().map_err(|e| bad("catalog.query", e.to_string()))?.unwrap_or_default();
s.catalog.query(&f, &so)
} else {
s.visible_cloned()
};
let off = p.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize;
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(200) as usize;
let items: Vec<Value> = ids.iter().skip(off).take(lim).filter_map(|id| s.catalog.photo(*id)).map(|p| photo_summary(p)).collect();
Ok(json!({"total": ids.len(), "photos": items}))
}),
cmd!(query "catalog.stats", "Catalog Statistics", [], None, "{}", always, |s, _| {
let all: Vec<_> = s.catalog.photos().filter(|p| !p.deleted).collect();
Ok(json!({
"photos": all.len(),
"edited": all.iter().filter(|p| p.is_edited()).count(),
"picks": all.iter().filter(|p| p.flag == lightcraft_catalog::Flag::Pick).count(),
"rejects": all.iter().filter(|p| p.flag == lightcraft_catalog::Flag::Reject).count(),
"deleted": s.catalog.photos().filter(|p| p.deleted).count(),
"albums": s.catalog.albums().filter(|a| !a.folder).count(),
"byDate": s.catalog.date_groups(),
"keywords": s.catalog.keywords(),
}))
}),
cmd!(query "library.state", "Library State", [], None, "{}", always, |s, _| {
let label = s.source.label(&s.catalog);
let n = s.visible().len();
Ok(json!({
"source": s.source,
"sourceLabel": label,
"filter": s.filter,
"sort": s.sort,
"selection": s.selection,
"visibleCount": n,
"activeMask": s.active_mask,
"undo": s.undo.last().map(|u| u.label.clone()),
"redo": s.redo.last().map(|u| u.label.clone()),
"clipboard": s.clipboard.is_some(),
}))
}),
cmd!(query "albums.list", "List Albums", [], None, "{}", always, |s, _| {
let all: Vec<Album> = s.catalog.albums().cloned().collect();
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all)).collect()))
}),
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
let id = photo_arg(s, p, "photo.inspect")?;
let ph = s.catalog.photo(id).ok_or_else(|| bad("photo.inspect", "no such photo"))?;
let mut v = serde_json::to_value(ph.as_ref()).unwrap_or_default();
v["albums"] = json!(s.catalog.albums_of(id).iter().map(|a| a.0).collect::<Vec<_>>());
v["history"] = json!(ph.history.iter().map(|h| h.label.clone()).collect::<Vec<_>>());
Ok(v)
}),
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {
let id = photo_arg(s, p, "develop.get")?;
Ok(s.develop_of(id).map(|d| d.to_json()).unwrap_or(Value::Null))
}),
cmd!(query "develop.controls", "List Develop Controls", [], None, "{section?} — every slider with range, default and current value", always, |s, p| {
let d = s.active().and_then(|id| s.develop_of(id)).unwrap_or_default();
let sec = p.get("section").and_then(|v| serde_json::from_value::<lightcraft_develop::Section>(v.clone()).ok());
Ok(Value::Array(
CONTROLS
.iter()
.filter(|c| sec.is_none_or(|x| x == c.section))
.map(|c| {
let mut v = serde_json::to_value(c).unwrap_or_default();
v["value"] = json!(controls::get(&d, c.id));
v
})
.collect(),
))
}),
cmd!(query "presets.list", "List Presets", [], None, "{}", always, |s, _| {
Ok(Value::Array(s.presets.iter().map(|p| json!({"id": p.id, "name": p.name, "group": p.group, "favorite": p.favorite, "builtin": p.builtin})).collect()))
}),
cmd!(query "profiles.list", "List Profiles", [], None, "{}", always, |_, _| Ok(serde_json::to_value(crate::presets::PROFILES).unwrap_or_default())),
cmd!(query "history.list", "List History", [], None, "{id?}", has_active, |s, p| {
let id = photo_arg(s, p, "history.list")?;
let ph = s.catalog.photo(id).ok_or_else(|| bad("history.list", "no such photo"))?;
Ok(json!({
"history": ph.history.iter().map(|h| h.label.clone()).collect::<Vec<_>>(),
"versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::<Vec<_>>(),
}))
}),
cmd!(query "journal.list", "Command Journal", [], None, "{limit?}", always, |s, p| {
let lim = p.get("limit").and_then(Value::as_u64).unwrap_or(100) as usize;
let start = s.journal.len().saturating_sub(lim);
Ok(Value::Array(s.journal[start..].iter().map(|(c, p)| json!({"command": c, "params": p})).collect()))
}),
]
}

84
crates/engine/src/demo.rs Normal file
View File

@ -0,0 +1,84 @@
//! The demo library: procedurally generated photos with realistic metadata, albums and a few edits.
use std::sync::Arc;
use lightcraft_catalog::{Album, Flag, Meta, Op, Photo, Source};
use lightcraft_develop::DevelopSettings;
use crate::Session;
pub fn load(s: &mut Session) {
let scenes = lightcraft_scenes::demo_library();
let mut ids = Vec::new();
let mut ops = Vec::new();
for sc in &scenes {
let id = s.catalog.alloc_photo_id();
let file = format!("LC{:05}.JPG", 1200 + sc.id * 7);
let mut p = Photo::new(id, Source::Demo { scene: sc.id }, &file, "JPEG", sc.width, sc.height, "2026-09-28T09:30:00");
p.captured = Some(sc.meta.captured.clone());
p.file_size = (sc.width as u64 * sc.height as u64) / 3;
p.meta = Meta {
camera: sc.meta.camera.into(),
lens: sc.meta.lens.into(),
focal_mm: Some(sc.meta.focal_mm),
aperture: Some(sc.meta.aperture),
shutter: sc.meta.shutter.into(),
iso: Some(sc.meta.iso),
location: sc.meta.location.into(),
title: sc.name.clone(),
keywords: sc.meta.keywords.iter().map(|k| k.to_string()).collect(),
creator: "LightCraft Demo".into(),
..Default::default()
};
p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8];
p.flag = match sc.id % 6 {
1 => Flag::Pick,
4 => Flag::Reject,
_ => Flag::None,
};
// A few photos come pre-edited so the grid shows the "edited" badge and variety.
let mut d = DevelopSettings::default();
match sc.id {
3 => {
d.light.highlights = -60.0;
d.light.shadows = 35.0;
d.color.vibrance = 25.0;
d.vignette.amount = -20.0;
}
4 => {
d.light.exposure = 0.35;
d.effects.clarity = 20.0;
d.effects.dehaze = 15.0;
}
9 => {
d.treatment = lightcraft_develop::Treatment::Bw;
d.light.contrast = 35.0;
}
_ => {}
}
p.develop = Arc::new(d);
ids.push(id);
ops.push(Op::AddPhoto { photo: Box::new(p) });
}
let folder = s.catalog.alloc_album_id();
ops.push(Op::AddAlbum { album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None } });
let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() };
for (name, parent, photos) in [
("Mountains", Some(folder), by_kw("mountains")),
("Coastlines", Some(folder), [by_kw("ocean"), by_kw("beach")].concat()),
("Deserts", Some(folder), by_kw("desert")),
("Night Sky", None, by_kw("night")),
("Garden", None, by_kw("flower")),
("Portfolio", None, ids.iter().copied().step_by(3).collect()),
] {
let id = s.catalog.alloc_album_id();
let cover = photos.first().copied();
ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover } });
}
for op in ops {
let _ = s.catalog.apply(op);
}
if let Some(first) = s.visible_cloned().first() {
s.selection = crate::Selection::single(*first);
}
}

301
crates/engine/src/lib.rs Normal file
View File

@ -0,0 +1,301 @@
//! The LightCraft engine façade.
//!
//! Every user-visible action is a command with a stable id (`photo.rate`, `develop.set`,
//! `album.create`, `mask.add`…) and JSON parameters. The egui UI, the CLI, the control channel and
//! the MCP server all go through [`Session::execute`].
//!
//! State: a [`Catalog`] (mutated only by ops, so every change is undoable and journaled), the
//! library view (filter/sort/source), the selection, the develop clipboard, presets, and caches
//! of decoded source proxies. Rendering is done by [`RenderJob`]s that are `Send` so frontends can
//! run them off the UI thread.
#![forbid(unsafe_code)]
pub mod cmd;
pub mod demo;
pub mod media;
pub mod presets;
mod view;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
use lightcraft_develop::DevelopSettings;
pub use media::{RenderJob, SourceLevel};
use serde_json::Value;
pub use view::{LibrarySource, Selection};
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_pipeline as pipeline};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
#[error("invalid parameters for `{cmd}`: {msg}")]
BadParams { cmd: String, msg: String },
#[error("{0}")]
Catalog(#[from] lightcraft_catalog::CatalogError),
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, EngineError>;
/// One undo step: the inverse op and a label.
#[derive(Clone, Debug)]
pub struct UndoEntry {
pub label: String,
pub op: Op,
}
/// An in-progress slider drag / brush stroke: one undo step when it ends.
#[derive(Clone, Debug)]
pub struct Interaction {
pub label: String,
pub photo: PhotoId,
pub original: Arc<DevelopSettings>,
}
pub struct Session {
pub catalog: Catalog,
pub source: LibrarySource,
pub filter: Filter,
pub sort: Sort,
pub selection: Selection,
/// The grid order for the current source/filter/sort (cached by catalog revision).
visible: Vec<PhotoId>,
visible_key: Option<(u64, String)>,
pub undo: Vec<UndoEntry>,
pub redo: Vec<UndoEntry>,
pub interaction: Option<Interaction>,
/// Copied develop settings (partial JSON) for Paste.
pub clipboard: Option<Value>,
/// Groups last used for Copy (Lightroom remembers them).
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
pub presets: Vec<lightcraft_develop::Preset>,
/// Executed commands (actions / debugging / replay).
pub journal: Vec<(String, Value)>,
/// Ops applied since the last `drain_log` (for persistence).
pending_log: Vec<Op>,
pub media: media::MediaCache,
/// Current time provider (ISO 8601); injectable for tests and wasm.
pub clock: Box<dyn Fn() -> String + Send>,
depth: u32,
/// Selected mask (Masking panel), by mask id.
pub active_mask: Option<u32>,
}
impl Default for Session {
fn default() -> Self {
Session::new()
}
}
impl Session {
pub fn new() -> Session {
Session {
catalog: Catalog::new(),
source: LibrarySource::All,
filter: Filter::default(),
sort: Sort::default(),
selection: Selection::default(),
visible: Vec::new(),
visible_key: None,
undo: Vec::new(),
redo: Vec::new(),
interaction: None,
clipboard: None,
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
presets: presets::builtin(),
journal: Vec::new(),
pending_log: Vec::new(),
media: media::MediaCache::default(),
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
depth: 0,
active_mask: None,
}
}
/// A session with the procedurally generated demo library loaded.
pub fn with_demo() -> Session {
let mut s = Session::new();
demo::load(&mut s);
s
}
/// Run a command by id. THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
self.depth += 1;
let r = (spec.run)(self, params);
self.depth -= 1;
if r.is_ok() && spec.journal && self.depth == 0 {
self.journal.push((id.to_string(), params.clone()));
if self.journal.len() > 10_000 {
self.journal.drain(..1000);
}
}
r
}
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
// ---------------------------------------------------------------- ops, undo
/// Apply an op as one undoable step.
pub fn commit(&mut self, label: &str, op: Op) -> Result<()> {
let fwd = op.clone();
let inv = self.catalog.apply(op)?;
self.pending_log.push(fwd);
self.undo.push(UndoEntry { label: label.to_string(), op: inv });
if self.undo.len() > 1000 {
self.undo.remove(0);
}
self.redo.clear();
Ok(())
}
/// Apply without recording undo (interactive previews).
fn apply_silent(&mut self, op: Op) -> Result<()> {
self.catalog.apply(op)?;
Ok(())
}
pub fn undo_step(&mut self) -> Result<String> {
let e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let redo = self.catalog.apply(e.op.clone())?;
self.pending_log.push(e.op);
self.redo.push(UndoEntry { label: e.label.clone(), op: redo });
Ok(e.label)
}
pub fn redo_step(&mut self) -> Result<String> {
let e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let undo = self.catalog.apply(e.op.clone())?;
self.pending_log.push(e.op);
self.undo.push(UndoEntry { label: e.label.clone(), op: undo });
Ok(e.label)
}
/// Ops applied since the last call, for the op-log store.
pub fn drain_log(&mut self) -> Vec<Op> {
std::mem::take(&mut self.pending_log)
}
// ---------------------------------------------------------------- develop edits
/// The photo being edited (the active photo).
pub fn active(&self) -> Option<PhotoId> {
self.selection.active.filter(|id| self.catalog.photo(*id).is_some())
}
pub fn develop_of(&self, id: PhotoId) -> Option<Arc<DevelopSettings>> {
self.catalog.photo(id).map(|p| p.develop.clone())
}
/// Change a photo's develop settings. During an interaction the change is previewed without an
/// undo step; otherwise it is committed with a history entry.
pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> {
let now = (self.clock)();
let settings = Arc::new(settings);
if let Some(i) = &self.interaction
&& i.photo == id
{
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
}
let op = self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
self.commit(label, op)
}
/// The op that sets a photo's develop settings and appends a History entry (for batches).
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
let p = self.catalog.photo(id)?;
let settings = Arc::new(settings);
let mut history = p.history.clone();
history.push(lightcraft_catalog::HistoryStep { label: label.into(), settings: settings.clone() });
if history.len() > 200 {
history.remove(0);
}
Some(Op::Batch {
ops: vec![Op::SetDevelop { id, settings, label: label.into(), edited: Some((self.clock)()) }, Op::SetHistory { id, history }],
})
}
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
if self.interaction.is_some() {
self.end_interaction()?;
}
let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?;
let original = self.develop_of(id).unwrap_or_default();
self.interaction = Some(Interaction { label: label.into(), photo: id, original });
Ok(())
}
/// Commit the interaction as one undo step (no-op if nothing changed).
pub fn end_interaction(&mut self) -> Result<()> {
let Some(i) = self.interaction.take() else { return Ok(()) };
let Some(cur) = self.develop_of(i.photo) else { return Ok(()) };
if *cur == *i.original {
return Ok(());
}
// Restore the original silently, then commit the final value as one step.
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?;
self.set_develop(i.photo, (*cur).clone(), &i.label)
}
pub fn cancel_interaction(&mut self) -> Result<()> {
if let Some(i) = self.interaction.take() {
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original, label: i.label, edited: None })?;
}
Ok(())
}
// ---------------------------------------------------------------- library view
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
pub fn visible(&mut self) -> &[PhotoId] {
let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.filter, self.sort));
if self.visible_key.as_ref() != Some(&key) {
let f = self.source.to_filter(&self.filter, &self.catalog);
self.visible = self.catalog.query(&f, &self.sort);
if matches!(self.source, LibrarySource::Album(_))
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
&& let LibrarySource::Album(a) = self.source
&& let Some(al) = self.catalog.album(a)
&& self.filter == Filter::default()
{
let order = al.photos.clone();
self.visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
if !self.sort.ascending {
self.visible.reverse();
}
}
self.visible_key = Some(key);
}
&self.visible
}
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
self.visible().to_vec()
}
/// Targets of photo commands: explicit `ids`/`id` param, else the selection.
pub fn targets(&self, p: &Value) -> Vec<PhotoId> {
if let Some(a) = p.get("ids").and_then(Value::as_array) {
return a.iter().filter_map(Value::as_u64).map(PhotoId).collect();
}
if let Some(id) = p.get("id").and_then(Value::as_u64) {
return vec![PhotoId(id)];
}
if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() }
}
}
#[cfg(test)]
mod tests;

244
crates/engine/src/media.rs Normal file
View File

@ -0,0 +1,244 @@
//! Source proxies and render jobs.
//!
//! Sources are decoded (or generated) once per level and cached: `Thumb` (≤ 512 px long edge) for
//! the grid and filmstrip, `Preview` (≤ 2560 px) for the loupe. A [`RenderJob`] is self-contained
//! and `Send`: frontends run it on a worker thread; if the source wasn't cached yet the job loads it
//! and hands it back in the [`RenderResult`] so the cache can keep it.
use std::collections::HashMap;
use std::sync::Arc;
use lightcraft_catalog::{MediaKind, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_pipeline::{RenderRequest, Rendered, SourceInfo};
use lightcraft_raster::Rgb32f;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceLevel {
Thumb,
Preview,
}
impl SourceLevel {
pub fn max_edge(self) -> usize {
match self {
SourceLevel::Thumb => 512,
SourceLevel::Preview => 2560,
}
}
/// Smallest level that can serve an output of `long_edge` pixels.
pub fn for_size(long_edge: usize) -> SourceLevel {
if long_edge <= 520 { SourceLevel::Thumb } else { SourceLevel::Preview }
}
}
/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app).
pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), String> + Send + Sync>;
#[derive(Clone)]
pub enum SourceRef {
Loaded(Arc<Rgb32f>),
Demo { scene: Box<lightcraft_scenes::Scene>, max_edge: usize },
File { path: String, max_edge: usize, loader: Option<FileLoader> },
}
impl SourceRef {
pub fn load(&self) -> Result<Arc<Rgb32f>, String> {
match self {
SourceRef::Loaded(a) => Ok(a.clone()),
SourceRef::Demo { scene, max_edge } => Ok(Arc::new(scene.render_fit(*max_edge))),
SourceRef::File { path, max_edge, loader } => match loader {
Some(l) => l(path, *max_edge).map(|(img, _)| Arc::new(img)),
None => Err(format!("no decoder available for {path}")),
},
}
}
}
#[derive(Default)]
pub struct MediaCache {
thumbs: HashMap<PhotoId, Arc<Rgb32f>>,
previews: Vec<(PhotoId, Arc<Rgb32f>)>,
/// How many previews to keep (LRU).
pub preview_capacity: usize,
pub file_loader: Option<FileLoader>,
pub file_probe: Option<FileProbe>,
scenes: Vec<lightcraft_scenes::Scene>,
}
impl MediaCache {
pub fn get(&self, id: PhotoId, level: SourceLevel) -> Option<Arc<Rgb32f>> {
match level {
SourceLevel::Thumb => self.thumbs.get(&id).cloned(),
SourceLevel::Preview => self.previews.iter().find(|(p, _)| *p == id).map(|(_, a)| a.clone()),
}
}
pub fn insert(&mut self, id: PhotoId, level: SourceLevel, img: Arc<Rgb32f>) {
match level {
SourceLevel::Thumb => {
self.thumbs.insert(id, img);
}
SourceLevel::Preview => {
self.previews.retain(|(p, _)| *p != id);
self.previews.push((id, img));
let cap = if self.preview_capacity == 0 { 10 } else { self.preview_capacity };
while self.previews.len() > cap {
self.previews.remove(0);
}
}
}
}
pub fn source_ref(&mut self, p: &Photo, level: SourceLevel) -> SourceRef {
if let Some(a) = self.get(p.id, level) {
return SourceRef::Loaded(a);
}
match &p.source {
Source::Demo { scene } => {
if self.scenes.is_empty() {
self.scenes = lightcraft_scenes::demo_library();
}
match self.scenes.iter().find(|s| s.id == *scene) {
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge: level.max_edge() },
None => SourceRef::File { path: format!("demo:{scene}"), max_edge: level.max_edge(), loader: None },
}
}
Source::File { path } => SourceRef::File { path: path.clone(), max_edge: level.max_edge(), loader: self.file_loader.clone() },
}
}
}
/// Everything needed to render one photo, detached from the session.
#[derive(Clone)]
pub struct RenderJob {
pub photo: PhotoId,
pub level: SourceLevel,
pub source: SourceRef,
pub info: SourceInfo,
pub settings: Arc<DevelopSettings>,
pub request: RenderRequest,
/// Identifies the result for caching: hash of settings + request.
pub key: u64,
}
pub struct RenderResult {
pub photo: PhotoId,
pub level: SourceLevel,
pub key: u64,
pub rendered: Result<Rendered, String>,
/// A source that was loaded by this job (to be inserted into the cache).
pub loaded: Option<Arc<Rgb32f>>,
}
impl RenderJob {
pub fn run(self) -> RenderResult {
let was_loaded = matches!(self.source, SourceRef::Loaded(_));
match self.source.load() {
Ok(src) => {
let rendered = lightcraft_pipeline::render(&src, &self.info, &self.settings, &self.request);
RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Ok(rendered), loaded: (!was_loaded).then_some(src) }
}
Err(e) => RenderResult { photo: self.photo, level: self.level, key: self.key, rendered: Err(e), loaded: None },
}
}
}
pub fn source_info(p: &Photo) -> SourceInfo {
if p.kind == MediaKind::Raw { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0 } } else { SourceInfo::default() }
}
impl crate::Session {
/// Build a render job for `id` fitting `max_w × max_h`. `before` renders the unedited look.
pub fn render_job(&mut self, id: PhotoId, max_w: usize, max_h: usize, before: bool, apply_crop: bool) -> Option<RenderJob> {
let p = self.catalog.photo(id)?.clone();
let level = SourceLevel::for_size(max_w.max(max_h));
let source = self.media.source_ref(&p, level);
let settings = if before { Arc::new(lightcraft_pipeline::before_settings(&p.develop)) } else { p.develop.clone() };
let request = RenderRequest { max_w, max_h, quality: lightcraft_pipeline::Quality::Full, apply_crop };
let key = settings.hash64()
^ ((max_w as u64) << 40)
^ ((max_h as u64) << 20)
^ (apply_crop as u64)
^ ((level == SourceLevel::Preview) as u64) << 60;
Some(RenderJob { photo: id, level, source, info: source_info(&p), settings, request, key })
}
/// Accept a finished job's loaded source into the cache.
pub fn accept(&mut self, r: &RenderResult) {
if let Some(src) = &r.loaded {
self.media.insert(r.photo, r.level, src.clone());
}
}
/// Synchronous render (CLI, MCP, tests).
pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result<Rendered, String> {
let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
let r = job.run();
self.accept(&r);
r.rendered
}
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
let r = self.media.source_ref(&p, level).load()?;
self.media.insert(id, level, r.clone());
Ok(r)
}
}
/// What an import learns from a file header (set by the app from `lightcraft-codecs`/`-raw`).
#[derive(Clone, Debug, Default)]
pub struct ProbeInfo {
pub width: u32,
pub height: u32,
pub format: String,
pub kind: MediaKind,
pub file_size: u64,
pub captured: Option<String>,
pub meta: lightcraft_catalog::Meta,
}
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
/// Register files as photos (duplicates by path are skipped). Returns the new ids.
pub fn import_paths(s: &mut crate::Session, paths: &[String]) -> crate::Result<Vec<PhotoId>> {
let now = (s.clock)();
let mut ops = Vec::new();
let mut ids = Vec::new();
for path in paths {
let exists = s.catalog.photos().any(|p| matches!(&p.source, Source::File { path: q } if q == path));
if exists {
continue;
}
let info = match &s.media.file_probe {
Some(probe) => match probe(path) {
Ok(i) => i,
Err(e) => {
log::warn!("import {path}: {e}");
continue;
}
},
None => ProbeInfo {
format: std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default(),
..Default::default()
},
};
let id = s.catalog.alloc_photo_id();
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.clone());
let mut p = Photo::new(id, Source::File { path: path.clone() }, &name, &info.format, info.width, info.height, &now);
p.kind = info.kind;
p.file_size = info.file_size;
p.captured = info.captured;
p.meta = info.meta;
ids.push(id);
ops.push(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) });
}
if !ops.is_empty() {
s.commit("Add Photos", lightcraft_catalog::Op::Batch { ops })?;
}
Ok(ids)
}

View File

@ -0,0 +1,150 @@
//! Built-in presets and profiles. All values are our own (no third-party preset content).
use lightcraft_develop::Preset;
use serde::Serialize;
use serde_json::json;
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);
}
}
}

168
crates/engine/src/tests.rs Normal file
View File

@ -0,0 +1,168 @@
use serde_json::{Value, json};
use crate::{Session, command_specs};
fn demo() -> Session {
Session::with_demo()
}
fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings {
(*s.develop_of(s.active().unwrap()).unwrap()).clone()
}
#[test]
fn demo_library_loads() {
let mut s = demo();
assert_eq!(s.visible().len(), 24);
assert!(s.active().is_some());
let st = s.execute("catalog.stats", &json!({})).unwrap();
assert_eq!(st["photos"], 24);
let albums = s.execute("albums.list", &json!({})).unwrap();
assert!(albums.as_array().unwrap().iter().any(|a| a["folder"] == true));
}
#[test]
fn rating_flag_undo_redo() {
let mut s = demo();
let id = s.active().unwrap();
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
s.execute("photo.flag", &json!({"flag": "reject"})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().rating, 5);
s.execute("edit.undo", &json!({})).unwrap();
assert_ne!(s.catalog.photo(id).unwrap().flag, lightcraft_catalog::Flag::Reject);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.catalog.photo(id).unwrap().flag, lightcraft_catalog::Flag::Reject);
assert!(s.execute("photo.rate", &json!({"rating": 7})).is_err());
}
#[test]
fn develop_set_and_interaction_coalesces() {
let mut s = demo();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap();
assert_eq!(active_dev(&s).light.exposure, 0.5);
let undo_before = s.undo.len();
s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap();
for v in [0.6, 0.8, 1.2] {
s.execute("develop.set", &json!({"control": "light.exposure", "value": v})).unwrap();
}
s.execute("develop.endInteraction", &json!({})).unwrap();
assert_eq!(s.undo.len(), undo_before + 1, "a drag is one undo step");
assert_eq!(active_dev(&s).light.exposure, 1.2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(active_dev(&s).light.exposure, 0.5);
// unknown control and bad values are rejected
assert!(s.execute("develop.set", &json!({"control": "nope", "value": 1})).is_err());
assert!(s.execute("develop.set", &json!({"values": {"light.contrast": "x"}})).is_err());
// clamped
s.execute("develop.set", &json!({"values": {"light.contrast": 500}})).unwrap();
assert_eq!(active_dev(&s).light.contrast, 100.0);
}
#[test]
fn copy_paste_sync_presets() {
let mut s = demo();
let vis = s.visible_cloned();
s.execute("develop.set", &json!({"values": {"effects.clarity": 30, "light.shadows": 20}})).unwrap();
s.execute("develop.copy", &json!({})).unwrap();
s.execute("library.select", &json!({"ids": [vis[1].0, vis[2].0]})).unwrap();
s.execute("develop.paste", &json!({})).unwrap();
assert_eq!(s.develop_of(vis[2]).unwrap().effects.clarity, 30.0);
s.execute("preset.apply", &json!({"id": "lc.bw-high-contrast", "amount": 100})).unwrap();
assert_eq!(s.develop_of(vis[1]).unwrap().treatment, lightcraft_develop::Treatment::Bw);
let r = s.execute("preset.create", &json!({"name": "Mine", "groups": ["light", "effects"]})).unwrap();
assert!(r["id"].as_str().unwrap().starts_with("user."));
assert!(s.execute("preset.delete", &json!({"id": "lc.moody"})).is_err());
}
#[test]
fn albums_crud() {
let mut s = demo();
let f = s.execute("album.create", &json!({"name": "Trips", "folder": true})).unwrap()["id"].as_u64().unwrap();
let a = s.execute("album.create", &json!({"name": "Best", "parent": f, "addSelected": true})).unwrap()["id"].as_u64().unwrap();
s.execute("library.source", &json!({"kind": "album", "id": a})).unwrap();
assert_eq!(s.visible().len(), 1);
s.execute("album.rename", &json!({"id": a, "name": "Best of"})).unwrap();
s.execute("library.source", &json!({"kind": "all"})).unwrap();
s.execute("library.selectAll", &json!({})).unwrap();
s.execute("album.addPhotos", &json!({"id": a})).unwrap();
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().photos.len(), 24);
assert!(s.execute("album.delete", &json!({"id": f})).is_err(), "non-empty folder");
s.execute("album.delete", &json!({"id": a})).unwrap();
s.execute("album.delete", &json!({"id": f})).unwrap();
}
#[test]
fn filters_and_delete_restore() {
let mut s = demo();
s.execute("library.filter", &json!({"rating": 4})).unwrap();
let n = s.visible().len();
assert!(n > 0 && n < 24);
s.execute("library.clearFilter", &json!({})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
assert_eq!(s.visible().len(), 23);
s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap();
assert_eq!(s.visible().len(), 1);
s.execute("photo.restore", &json!({})).unwrap();
s.execute("library.source", &json!({"kind": "all"})).unwrap();
assert_eq!(s.visible().len(), 24);
}
#[test]
fn masks_crop_and_render() {
let mut s = demo();
s.execute("mask.add", &json!({"kind": "radial", "center": [0.5, 0.5]})).unwrap();
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "size": 0.05})).unwrap();
let d = active_dev(&s);
assert_eq!(d.masks.len(), 1);
assert_eq!(d.masks[0].components.len(), 2);
s.execute("crop.aspect", &json!({"aspect": "1x1"})).unwrap();
s.execute("crop.straighten", &json!({"angle": 5.0})).unwrap();
let id = s.active().unwrap();
let r = s.render_now(id, 200, 200).unwrap();
assert_eq!((r.image.width, r.image.height), (200, 200));
s.execute("develop.auto", &json!({})).unwrap();
s.execute("develop.wb", &json!({"mode": "auto"})).unwrap();
s.execute("develop.wbPick", &json!({"x": 0.5, "y": 0.5})).unwrap();
}
#[test]
fn versions_history_and_reset() {
let mut s = demo();
s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap();
s.execute("version.create", &json!({"name": "bright"})).unwrap();
s.execute("develop.reset", &json!({})).unwrap();
assert!(active_dev(&s).is_unedited());
s.execute("version.restore", &json!({"index": 0})).unwrap();
assert_eq!(active_dev(&s).light.exposure, 1.0);
let h = s.execute("history.list", &json!({})).unwrap();
assert!(h["history"].as_array().unwrap().len() >= 3);
}
#[test]
fn op_log_replay_reproduces_catalog() {
let mut s = demo();
let snap = s.catalog.to_snapshot();
let _ = s.drain_log();
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 40})).unwrap();
s.execute("album.create", &json!({"name": "X"})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect();
let mut c = lightcraft_catalog::Catalog::from_snapshot(&snap).unwrap();
c.replay(&log).unwrap();
assert_eq!(c.to_snapshot(), s.catalog.to_snapshot());
}
/// Every command runs (or fails cleanly with an error) with empty params and has metadata.
#[test]
fn command_sweep() {
let mut s = demo();
let mut ids = std::collections::HashSet::new();
for c in command_specs() {
assert!(ids.insert(c.id), "duplicate {}", c.id);
assert!(!c.label.is_empty() && !c.params.is_empty(), "{}", c.id);
let _ = s.execute(c.id, &Value::Null);
}
assert!(s.commands().len() >= 70, "{}", s.commands().len());
}

87
crates/engine/src/view.rs Normal file
View File

@ -0,0 +1,87 @@
//! Library view state: what the grid shows and what is selected.
use lightcraft_catalog::{AlbumId, Catalog, Filter, PhotoId};
use serde::{Deserialize, Serialize};
/// The "My Photos" source in the left panel.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum LibrarySource {
#[default]
All,
/// Imported in the last import session (we treat "recent" as the latest import date).
RecentlyAdded,
Album(AlbumId),
RecentlyDeleted,
/// Photos with picks.
Picks,
}
impl LibrarySource {
pub fn to_filter(&self, f: &Filter, cat: &Catalog) -> Filter {
let mut f = f.clone();
match self {
LibrarySource::All => {}
LibrarySource::RecentlyAdded => {
let latest = cat.photos().map(|p| p.imported.clone()).max().unwrap_or_default();
f.imported = Some(latest.get(..10).unwrap_or("").to_string());
}
LibrarySource::Album(a) => f.album = Some(*a),
LibrarySource::RecentlyDeleted => f.deleted = true,
LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick),
}
f
}
pub fn label(&self, cat: &Catalog) -> String {
match self {
LibrarySource::All => "All Photos".into(),
LibrarySource::RecentlyAdded => "Recently Added".into(),
LibrarySource::Album(a) => cat.album(*a).map(|a| a.name.clone()).unwrap_or_else(|| "Album".into()),
LibrarySource::RecentlyDeleted => "Recently Deleted".into(),
LibrarySource::Picks => "Picks".into(),
}
}
}
/// Selected photos (ordered) and the active (most-selected) one.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Selection {
pub ids: Vec<PhotoId>,
pub active: Option<PhotoId>,
}
impl Selection {
pub fn single(id: PhotoId) -> Selection {
Selection { ids: vec![id], active: Some(id) }
}
pub fn contains(&self, id: PhotoId) -> bool {
self.ids.contains(&id)
}
pub fn toggle(&mut self, id: PhotoId) {
if let Some(i) = self.ids.iter().position(|x| *x == id) {
self.ids.remove(i);
if self.active == Some(id) {
self.active = self.ids.last().copied();
}
} else {
self.ids.push(id);
self.active = Some(id);
}
}
/// Shift-click: select the range between the active photo and `id` in `order`.
pub fn extend_to(&mut self, id: PhotoId, order: &[PhotoId]) {
let anchor = self.active.unwrap_or(id);
let (Some(a), Some(b)) = (order.iter().position(|x| *x == anchor), order.iter().position(|x| *x == id)) else {
*self = Selection::single(id);
return;
};
let (lo, hi) = (a.min(b), a.max(b));
for x in &order[lo..=hi] {
if !self.ids.contains(x) {
self.ids.push(*x);
}
}
self.active = Some(id);
}
}