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:
parent
fe0ca43eed
commit
69e8aa6e18
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -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()?}))),
|
||||
]
|
||||
}
|
||||
|
|
@ -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<_>>()}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()?))
|
||||
}
|
||||
|
|
@ -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()))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue