1200 lines
61 KiB
Rust
1200 lines
61 KiB
Rust
//! Develop commands. All operate on the active photo unless `ids` are given (sync).
|
||
|
||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||
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) }
|
||
}
|
||
|
||
/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`:
|
||
/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in
|
||
/// canvas outside the photo (issue #742).
|
||
fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) {
|
||
if touched {
|
||
d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h);
|
||
}
|
||
}
|
||
|
||
const CROP_ANGLE: &str = "crop.angle";
|
||
|
||
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
|
||
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
|
||
v?.as_array()?.iter().map(Value::as_f64).collect()
|
||
}
|
||
|
||
/// A width × height lock stored as hundredths. Rejects non-finite, non-positive and
|
||
/// extreme (beyond 20:1) shapes, which would give an empty or unusable crop.
|
||
fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
|
||
let ratio = x / y;
|
||
if !(x.is_finite()
|
||
&& y.is_finite()
|
||
&& x > 0.0
|
||
&& y > 0.0
|
||
&& ratio.is_finite()
|
||
&& (1.0 / lightcraft_geom::MAX_RATIO..=lightcraft_geom::MAX_RATIO).contains(&ratio))
|
||
{
|
||
return Err(bad(c, "aspect needs two positive numbers with a ratio between 1:20 and 20:1"));
|
||
}
|
||
let to = |v: f64| (v * 100.0).round();
|
||
let (a, b) = (to(x), to(y));
|
||
if a < 1.0 || b < 1.0 || a > f64::from(u32::MAX) || b > f64::from(u32::MAX) {
|
||
return Err(bad(c, "aspect numbers are out of range"));
|
||
}
|
||
Ok((a as u32, b as u32))
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`].
|
||
fn outdated(s: &Session, id: PhotoId) -> bool {
|
||
s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated())
|
||
}
|
||
|
||
fn already_current() -> String {
|
||
format!("already on the current process (version {})", ProcessVersion::LATEST.0)
|
||
}
|
||
|
||
/// Update to Current Process applies to the selection (the selected photos, else the active
|
||
/// photo): one of them is on an older rendering process. Calls that name their photos are
|
||
/// checked by the command instead (`explicit_targets`).
|
||
fn has_outdated_process(s: &Session) -> std::result::Result<(), String> {
|
||
has_selection(s)?;
|
||
let sel = &s.selection;
|
||
let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() };
|
||
if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) }
|
||
}
|
||
|
||
/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it
|
||
/// names none (the selection applies).
|
||
fn named_photos(s: &Session, p: &Value, c: &str) -> Result<Option<Vec<PhotoId>>> {
|
||
if !super::names_photos(p) {
|
||
return Ok(None);
|
||
}
|
||
let given = match p.get("ids").filter(|v| !v.is_null()) {
|
||
Some(Value::Array(a)) => a.clone(),
|
||
Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")),
|
||
None => p.get("id").cloned().into_iter().collect(),
|
||
};
|
||
if given.is_empty() {
|
||
return Err(bad(c, "`ids` names no photos"));
|
||
}
|
||
let ids = given
|
||
.iter()
|
||
.map(|v| match v.as_u64().map(PhotoId) {
|
||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||
_ => Err(bad(c, format!("no such photo {v}"))),
|
||
})
|
||
.collect::<Result<Vec<_>>>()?;
|
||
Ok(Some(ids))
|
||
}
|
||
|
||
/// The user preset named by `p.id` (built-ins can't be changed).
|
||
fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> {
|
||
let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?;
|
||
let pr = s.presets.iter_mut().find(|x| x.id == pid).ok_or_else(|| bad(c, format!("unknown preset `{pid}`")))?;
|
||
if pr.builtin {
|
||
return Err(bad(c, "built-in presets can't be changed"));
|
||
}
|
||
Ok(pr)
|
||
}
|
||
|
||
/// Change version `p.index` of the active photo (`f` gets it and the current settings).
|
||
fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc<DevelopSettings>)) -> Result<Value> {
|
||
let id = active(s, c)?;
|
||
let i = f64_req(p, "index", c)? as usize;
|
||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||
let mut versions = ph.versions.clone();
|
||
let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?;
|
||
f(v, ph.develop.clone());
|
||
s.commit(label, Op::SetVersions { id, versions })?;
|
||
ok()
|
||
}
|
||
|
||
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; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)",
|
||
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 angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
|
||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
|
||
let before = d.crop.geometry;
|
||
if vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||
lightcraft_pipeline::local::normalize_wb(d, info);
|
||
if d.wb.mode == WbMode::AsShot {
|
||
d.wb.temp = info.as_shot_temp;
|
||
d.wb.tint = info.as_shot_tint;
|
||
}
|
||
}
|
||
for (k, v) in &vals {
|
||
controls::set(d, k, *v);
|
||
if k == "wb.temp" || k == "wb.tint" {
|
||
d.wb.mode = WbMode::Custom;
|
||
}
|
||
}
|
||
refit_crop_angle(angle, before, d, dims);
|
||
};
|
||
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();
|
||
let info = s.source_info(id);
|
||
apply(&mut d, &info, crop_dims(s, id));
|
||
s.develop_op(id, d, &label)
|
||
})
|
||
.collect();
|
||
s.commit(&label, Op::Batch { ops })?;
|
||
return ok();
|
||
}
|
||
let id = active(s, "develop.set")?;
|
||
let info = s.source_info(id);
|
||
let dims = crop_dims(s, id);
|
||
edit(s, "develop.set", &label, |d| {
|
||
apply(d, &info, dims);
|
||
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")?;
|
||
let id = active(s, "develop.adjust")?;
|
||
let (info, dims) = (s.source_info(id), crop_dims(s, id));
|
||
edit(s, "develop.adjust", spec.label, |d| {
|
||
let before = d.crop.geometry;
|
||
if c == "wb.temp" || c == "wb.tint" {
|
||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||
if d.wb.mode == WbMode::AsShot {
|
||
d.wb.temp = info.as_shot_temp;
|
||
d.wb.tint = info.as_shot_tint;
|
||
}
|
||
d.wb.mode = WbMode::Custom;
|
||
}
|
||
let v = controls::get(d, &c).unwrap_or(spec.default);
|
||
controls::set(d, &c, v + delta);
|
||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||
Ok(())
|
||
})
|
||
}),
|
||
cmd!(
|
||
"develop.merge",
|
||
"Apply Settings JSON",
|
||
[],
|
||
None,
|
||
"{settings: partial DevelopSettings JSON, label?} (a change of `orientation` turns the photo's Upright correction with it, also when `geometry.upright_transform` states the photo's own as it was read: before a turn or with its numbers rounded; a different one is taken as given; a change of `geometry.upright` drops it to be solved again)",
|
||
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| {
|
||
let before = d.clone();
|
||
*d = d.merged(&partial).map_err(|e| bad("develop.merge", e.to_string()))?;
|
||
// a turn turns the Upright correction the photo keeps, as Rotate does; a new mode
|
||
// drops it; a correction stated in the JSON is taken as given, unless it is the
|
||
// photo's own as it was read (settings sent back whole, in any frame)
|
||
crate::upright_transfer::settle_json(&before, d, &partial);
|
||
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)| {
|
||
// back to the photo's import defaults when a default preset gave it its look
|
||
let look = s.catalog.photo(id).and_then(|p| p.import_look.clone());
|
||
let fresh = match look {
|
||
Some(l) => (*l).clone(),
|
||
None => {
|
||
// the photo's built-in defaults (raws: capture sharpening and colour noise
|
||
// reduction, embedded lens corrections), as at import, with the white balance
|
||
// of its current as-shot reference
|
||
let info = s.source_info(id);
|
||
let defaults = s.catalog.photo(id).map(|p| p.camera_defaults()).unwrap_or_default();
|
||
let mut fresh = DevelopSettings {
|
||
wb: lightcraft_develop::WhiteBalance {
|
||
mode: WbMode::AsShot,
|
||
temp: info.as_shot_temp,
|
||
tint: info.as_shot_tint,
|
||
..Default::default()
|
||
},
|
||
..defaults
|
||
};
|
||
lightcraft_pipeline::local::normalize_wb(&mut fresh, &info);
|
||
fresh
|
||
}
|
||
};
|
||
// a fresh start renders with the current process (a section or slider reset doesn't change it)
|
||
s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset")
|
||
})
|
||
.collect();
|
||
s.commit("Reset", Op::Batch { ops })?;
|
||
ok()
|
||
}),
|
||
CommandSpec {
|
||
explicit_targets: true,
|
||
..cmd!(
|
||
"develop.updateProcess",
|
||
"Update to Current Process",
|
||
["Photo"],
|
||
None,
|
||
"{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}",
|
||
has_outdated_process,
|
||
|s, p| {
|
||
const C: &str = "develop.updateProcess";
|
||
const LABEL: &str = "Update to Current Process";
|
||
let targets = match named_photos(s, p, C)? {
|
||
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
|
||
Some(ids) => ids,
|
||
None => s.targets(p),
|
||
};
|
||
let ops: Vec<Op> = targets
|
||
.into_iter()
|
||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||
.filter(|(_, d)| d.process.is_outdated())
|
||
.filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL))
|
||
.collect();
|
||
let n = ops.len();
|
||
if n > 0 {
|
||
s.commit(LABEL, Op::Batch { ops })?;
|
||
}
|
||
Ok(json!({"changed": n, "process": ProcessVersion::LATEST}))
|
||
}
|
||
)
|
||
},
|
||
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"))?;
|
||
let dims = crop_dims(s, active(s, "develop.resetControl")?);
|
||
edit(s, "develop.resetControl", spec.label, |d| {
|
||
let before = d.crop.geometry;
|
||
controls::set(d, &c, spec.default);
|
||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||
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.autoBwMix",
|
||
"Auto B&W Mix",
|
||
[],
|
||
None,
|
||
"{} — sets the black & white mix from the photo's colours (and switches to B&W)",
|
||
has_active,
|
||
|s, _| {
|
||
let id = active(s, "develop.autoBwMix")?;
|
||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad("develop.autoBwMix", e))?;
|
||
let info = s.source_info(id);
|
||
let d = s.develop_of(id).unwrap_or_default();
|
||
let m = lightcraft_pipeline::auto::auto_bw_mix(&src, &info, &d);
|
||
edit(s, "develop.autoBwMix", "Auto B&W Mix", |d| {
|
||
d.treatment = Treatment::Bw;
|
||
let b = &mut d.bw_mix;
|
||
[b.red, b.orange, b.yellow, b.green, b.aqua, b.blue, b.purple, b.magenta] = m;
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"mix": m}))
|
||
}
|
||
),
|
||
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.source_info(id);
|
||
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.source_info(id);
|
||
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 Kelvin on raws with that scale, relative to daylight (6500 K = as shot) elsewhere
|
||
if info.raw && !info.relative_wb { (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| {
|
||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||
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.source_info(id);
|
||
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| {
|
||
lightcraft_pipeline::local::normalize_wb(d, &info);
|
||
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) && !s.lut_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.clone();
|
||
d.profile.amount = f64_or(p, "amount", d.profile.amount).clamp(0.0, 200.0);
|
||
Ok(())
|
||
})?;
|
||
s.note_profile_used(&id);
|
||
ok()
|
||
}),
|
||
cmd!("profile.favorite", "Favorite Profile", [], None, "{id, favorite?: bool} (toggles when omitted)", always, |s, p| {
|
||
let id = str_param(p, "id").ok_or_else(|| bad("profile.favorite", "missing id"))?.to_string();
|
||
if crate::presets::profile(&id).is_none() && !s.lut_profiles.iter().any(|x| x.id == id) {
|
||
return Err(bad("profile.favorite", format!("unknown profile `{id}`")));
|
||
}
|
||
let on = s.profile_favorites.contains(&id);
|
||
let want = bool_or(p, "favorite", !on);
|
||
if want && !on {
|
||
s.profile_favorites.push(id);
|
||
} else if !want {
|
||
s.profile_favorites.retain(|f| *f != id);
|
||
}
|
||
Ok(json!({"favorite": want}))
|
||
}),
|
||
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]));
|
||
}
|
||
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
|
||
d.crop.geometry = match (p.get("rect"), angle) {
|
||
// a new angle alone turns the crop as Straighten does (issue #742)
|
||
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
|
||
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
|
||
};
|
||
Ok(())
|
||
})
|
||
}),
|
||
cmd!(
|
||
"crop.drag",
|
||
"Drag Crop Handle",
|
||
[],
|
||
None,
|
||
"{handle: topLeft|topRight|bottomRight|bottomLeft|top|right|bottom|left|move | 0..8, from: [x,y], to: [x,y] (normalized, straightened frame), start?: [x0,y0,x1,y1] (default: the current crop), keepRatio?: bool (default false)} — drags a crop handle; stops at the image edge, keeps the opposite edges, honours the aspect lock; keepRatio holds the frame's current proportions for this drag even with the lock off (the UI sends it while Shift is held)",
|
||
has_active,
|
||
|s, p| {
|
||
let id = active(s, "crop.drag")?;
|
||
let (w, h) = crop_dims(s, id);
|
||
let handle = match p.get("handle") {
|
||
Some(Value::String(n)) => CropHandle::from_name(n),
|
||
Some(v) => v.as_u64().and_then(|i| u8::try_from(i).ok()).and_then(CropHandle::from_index),
|
||
None => None,
|
||
}
|
||
.ok_or_else(|| bad("crop.drag", "unknown handle"))?;
|
||
let point = |k: &str| -> Result<Point> {
|
||
match numbers(p.get(k)).as_deref() {
|
||
Some([x, y]) if x.is_finite() && y.is_finite() => Ok(Point::new(*x, *y)),
|
||
_ => Err(bad("crop.drag", format!("`{k}` needs [x, y]"))),
|
||
}
|
||
};
|
||
let (from, to) = (point("from")?, point("to")?);
|
||
let given = match (p.get("start"), numbers(p.get("start")).as_deref()) {
|
||
(None | Some(Value::Null), _) => None,
|
||
(_, Some(&[x0, y0, x1, y1])) if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) => {
|
||
Some(Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)))
|
||
}
|
||
_ => return Err(bad("crop.drag", "`start` needs 4 numbers")),
|
||
};
|
||
let keep_ratio = bool_or(p, "keepRatio", false);
|
||
edit(s, "crop.drag", "Crop", |d| {
|
||
let start = CropGeometry { rect: given.unwrap_or(d.crop.geometry.rect), angle: d.crop.geometry.angle };
|
||
// A locked crop keeps the shape it has: the lock stores a width:height tuple whose
|
||
// orientation is reinterpreted per image, so the frame itself is the truth.
|
||
// `keepRatio` (Shift held) asks for the same thing for one drag without touching the lock.
|
||
let ratio = (d.crop.aspect.is_some() || keep_ratio).then(|| start.rect_px(w, h).aspect()).filter(|a| a.is_finite() && *a > 0.0);
|
||
d.crop.geometry = drag_crop(start, handle, from, to, w, h, ratio);
|
||
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| {
|
||
// keep the current crop if it still fits, else the largest of its aspect that does
|
||
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
|
||
Ok(())
|
||
})
|
||
}
|
||
),
|
||
cmd!(
|
||
"crop.autoStraighten",
|
||
"Auto Straighten",
|
||
[],
|
||
None,
|
||
"{} — level the horizon (or plumb lines) with the crop angle",
|
||
has_active,
|
||
|s, _| {
|
||
let id = active(s, "crop.autoStraighten")?;
|
||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?;
|
||
let info = s.source_info(id);
|
||
let d = s.develop_of(id).unwrap_or_default();
|
||
let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else {
|
||
return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"}));
|
||
};
|
||
// Same convention as the crop angle (verified: a horizon descending 6° to the right
|
||
// comes out level with angle = level ≈ −6°).
|
||
let angle = level.clamp(-45.0, 45.0);
|
||
s.execute("crop.straighten", &json!({"angle": (angle * 100.0).round() / 100.0}))?;
|
||
Ok(json!({"changed": true, "angle": angle}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"crop.aspect",
|
||
"Crop Aspect",
|
||
[],
|
||
None,
|
||
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ 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 locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some());
|
||
let lock_current = match p.get("aspect").and_then(Value::as_str) {
|
||
Some("current") => true,
|
||
Some("toggle") => !locked,
|
||
_ => false,
|
||
};
|
||
if lock_current {
|
||
// lock to the rectangle as it is: no refit
|
||
return edit(s, "crop.aspect", "Lock Crop Aspect", |d| {
|
||
let r = d.crop.geometry.rect_px(w, h);
|
||
d.crop.aspect = Some(((r.width() * 100.0).round().max(1.0) as u32, (r.height() * 100.0).round().max(1.0) as u32));
|
||
Ok(())
|
||
});
|
||
}
|
||
let aspect = match p.get("aspect") {
|
||
Some(Value::String(a)) if a == "toggle" => None,
|
||
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(aspect_tuple("crop.aspect", x, y)?)
|
||
}
|
||
Some(v @ Value::Array(_)) => match numbers(Some(v)).as_deref() {
|
||
Some(&[x, y]) => Some(aspect_tuple("crop.aspect", x, y)?),
|
||
_ => return Err(bad("crop.aspect", "use [w, h] with two numbers")),
|
||
},
|
||
_ => 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(())
|
||
})
|
||
}),
|
||
// ---- HDR editing
|
||
cmd!(
|
||
"develop.hdr",
|
||
"HDR",
|
||
[],
|
||
None,
|
||
"{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)",
|
||
has_active,
|
||
|s, p| {
|
||
let id = active(s, "develop.hdr")?;
|
||
let cur = s.develop_of(id).unwrap_or_default().hdr;
|
||
let enabled = bool_or(p, "enabled", !cur.enabled);
|
||
let max_ev = match p.get("maxEv") {
|
||
None => None,
|
||
Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?),
|
||
};
|
||
let label = if enabled { "HDR On" } else { "HDR Off" };
|
||
edit(s, "develop.hdr", label, |d| {
|
||
d.hdr.enabled = enabled;
|
||
if let Some(m) = max_ev {
|
||
d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT);
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)}))
|
||
}
|
||
),
|
||
// ---- geometry: Upright
|
||
cmd!(
|
||
"geometry.upright",
|
||
"Upright",
|
||
[],
|
||
None,
|
||
"{mode: off|auto|guided|level|vertical|full} — analyses the photo (line segments → vanishing points) and stores the correction; run again to update after lens changes",
|
||
has_active,
|
||
|s, p| {
|
||
let mode: Upright =
|
||
serde_json::from_value(p.get("mode").cloned().unwrap_or(json!("auto"))).map_err(|e| bad("geometry.upright", e.to_string()))?;
|
||
let id = active(s, "geometry.upright")?;
|
||
let d = s.develop_of(id).unwrap_or_default();
|
||
let transform = match mode {
|
||
Upright::Off | Upright::Guided => None,
|
||
m => {
|
||
let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("geometry.upright", e))?;
|
||
let info = s.source_info(id);
|
||
Some(lightcraft_pipeline::upright::auto_transform(&src, &info, &d, m))
|
||
}
|
||
};
|
||
edit(s, "geometry.upright", "Upright", |d| {
|
||
d.geometry.upright = mode;
|
||
d.geometry.upright_transform = transform;
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"mode": mode, "transform": transform}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"geometry.guides",
|
||
"Guided Upright Guides",
|
||
[],
|
||
None,
|
||
"{guides: [[x0,y0,x1,y1], …] (≤ 4, normalized coords of the lens-corrected image), add?: bool (append one guide, dropping the oldest beyond 4)} — switches Upright to Guided",
|
||
has_active,
|
||
|s, p| {
|
||
let parse = |v: &Value| -> Option<(Point, Point)> {
|
||
let a: Vec<f64> = v.as_array()?.iter().filter_map(Value::as_f64).collect();
|
||
(a.len() == 4 && a.iter().all(|x| x.is_finite())).then(|| (Point::new(a[0], a[1]), Point::new(a[2], a[3])))
|
||
};
|
||
let guides: Vec<(Point, Point)> =
|
||
p.get("guides").and_then(Value::as_array).map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default();
|
||
let add = bool_or(p, "add", false);
|
||
edit(s, "geometry.guides", "Guided Upright", |d| {
|
||
if add {
|
||
d.geometry.guides.extend(guides);
|
||
} else {
|
||
d.geometry.guides = guides;
|
||
}
|
||
let n = d.geometry.guides.len();
|
||
if n > 4 {
|
||
d.geometry.guides.drain(..n - 4);
|
||
}
|
||
d.geometry.upright = Upright::Guided;
|
||
d.geometry.upright_transform = None;
|
||
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));
|
||
s.clipboard_source = s.catalog.photo(id).map(|p| crate::upright_transfer::source_of(p, &d, &s.library_identity));
|
||
Ok(json!({"groups": s.copy_groups}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"develop.pastePrevious",
|
||
"Paste Settings from Previous",
|
||
["Edit"],
|
||
Some("Cmd+Alt+V"),
|
||
"{ids?, groups?: [settingsGroup] (default: the copy groups)} — from the photo that was active before this one",
|
||
has_active,
|
||
|s, p| {
|
||
let c = "develop.pastePrevious";
|
||
let prev =
|
||
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
|
||
let groups = groups_param(p).unwrap_or_else(|| s.copy_groups.clone());
|
||
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
|
||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
|
||
let source = s.catalog.photo(prev).map(|ph| crate::upright_transfer::source_of(ph, &ph.develop, &s.library_identity));
|
||
let ops = ids
|
||
.iter()
|
||
.filter_map(|id| {
|
||
s.catalog.photo(*id).map(|ph| (*id, crate::upright_transfer::transferred(ph, &ph.develop, &partial, source.as_ref())))
|
||
})
|
||
.filter_map(|(id, d)| s.develop_op(id, d, "Paste from Previous"))
|
||
.collect::<Vec<_>>();
|
||
let n = ops.len();
|
||
s.commit("Paste from Previous", Op::Batch { ops })?;
|
||
Ok(json!({"changed": n, "from": prev.0}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"develop.paste",
|
||
"Paste Edit Settings",
|
||
["Edit"],
|
||
Some("Cmd+V"),
|
||
"{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}",
|
||
has_clipboard,
|
||
|s, p| {
|
||
let mut clip = s.clipboard.clone().unwrap_or_default();
|
||
if let Some(only) = groups_param(p) {
|
||
// the clipboard holds whole groups: rebuild it with just the chosen (copied) ones
|
||
let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect();
|
||
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
|
||
clip = lightcraft_develop::extract_groups(&full, &only);
|
||
}
|
||
let ids = s.targets(p);
|
||
// copied in another library: its photo ids say nothing about this one's
|
||
let source = s.clipboard_source.clone().filter(|from| from.taken_in(&s.library_identity));
|
||
let ops = ids
|
||
.iter()
|
||
.filter_map(|id| {
|
||
s.catalog.photo(*id).map(|ph| (*id, crate::upright_transfer::transferred(ph, &ph.develop, &clip, source.as_ref())))
|
||
})
|
||
.filter_map(|(id, d)| s.develop_op(id, d, "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 source = s.catalog.photo(id).map(|ph| crate::upright_transfer::source_of(ph, &d, &s.library_identity));
|
||
let ops = s
|
||
.selection
|
||
.ids
|
||
.clone()
|
||
.into_iter()
|
||
.filter(|x| *x != id)
|
||
.filter_map(|x| {
|
||
s.catalog.photo(x).map(|ph| (x, crate::upright_transfer::transferred(ph, &ph.develop, &partial, source.as_ref())))
|
||
})
|
||
.filter_map(|(x, dd)| s.develop_op(x, dd, "Sync Settings"))
|
||
.collect::<Vec<_>>();
|
||
let n = ops.len();
|
||
s.commit("Sync Settings", Op::Batch { ops })?;
|
||
Ok(json!({"changed": n}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"develop.quickAdjust",
|
||
"Quick Develop",
|
||
[],
|
||
None,
|
||
"{control: develop control id, delta, ids?} — add `delta` to the control on every target photo (each from its own value; Quick Develop) in one undo step → {changed}",
|
||
has_selection,
|
||
|s, p| {
|
||
let c = "develop.quickAdjust";
|
||
let ctl = str_param(p, "control").ok_or_else(|| bad(c, "missing control"))?.to_string();
|
||
let spec = controls::find(&ctl).ok_or_else(|| bad(c, format!("unknown control `{ctl}`")))?;
|
||
let delta = f64_req(p, "delta", c)?;
|
||
let label = format!("Quick Develop: {}", spec.label);
|
||
let ops: Vec<Op> = s
|
||
.targets(p)
|
||
.into_iter()
|
||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||
.filter_map(|(id, d)| {
|
||
let mut nd = (*d).clone();
|
||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||
// each photo on its own scale: Kelvin (an old relative value converted first) or relative
|
||
let info = s.source_info(id);
|
||
lightcraft_pipeline::local::normalize_wb(&mut nd, &info);
|
||
if nd.wb.mode == WbMode::AsShot {
|
||
nd.wb.temp = info.as_shot_temp;
|
||
nd.wb.tint = info.as_shot_tint;
|
||
}
|
||
}
|
||
let before = nd.crop.geometry;
|
||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||
controls::set(&mut nd, &ctl, v + delta);
|
||
refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id));
|
||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||
nd.wb.mode = WbMode::Custom;
|
||
}
|
||
s.develop_op(id, nd, &label)
|
||
})
|
||
.collect();
|
||
let n = ops.len();
|
||
if n > 0 {
|
||
s.commit(&label, Op::Batch { ops })?;
|
||
}
|
||
Ok(json!({"changed": n}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"develop.matchExposure",
|
||
"Match Total Exposures",
|
||
["Photo"],
|
||
None,
|
||
"{ids?} — set each selected photo's Exposure so its total exposure (shutter × ISO ÷ aperture², plus the slider) equals the active photo's; photos without exposure data are skipped → {changed, skipped}",
|
||
has_selection,
|
||
|s, p| {
|
||
let c = "develop.matchExposure";
|
||
let reference = active(s, c)?;
|
||
// log2 of the light the photo gathered (shutter s × ISO / f-number²)
|
||
let gathered = |s: &Session, id: PhotoId| -> Option<f64> {
|
||
let m = &s.catalog.photo(id)?.meta;
|
||
let t = match m.shutter.split_once('/') {
|
||
Some((a, b)) => a.trim().parse::<f64>().ok()? / b.trim().parse::<f64>().ok()?,
|
||
None => m.shutter.trim().trim_end_matches('s').trim().parse::<f64>().ok()?,
|
||
};
|
||
let (n, iso) = (m.aperture? as f64, m.iso? as f64);
|
||
(t > 0.0 && n > 0.0 && iso > 0.0).then(|| (t * iso / (n * n)).log2())
|
||
};
|
||
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
|
||
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
|
||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
|
||
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
|
||
for id in ids {
|
||
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
|
||
skipped.push(id.0);
|
||
continue;
|
||
};
|
||
let mut nd = (*d).clone();
|
||
nd.light.exposure = (r_slider + r_ev - ev).clamp(-5.0, 5.0);
|
||
ops.extend(s.develop_op(id, nd, "Match Total Exposures"));
|
||
}
|
||
let n = ops.len();
|
||
if n > 0 {
|
||
s.commit("Match Total Exposures", Op::Batch { ops })?;
|
||
}
|
||
Ok(json!({"changed": n, "skipped": skipped}))
|
||
}
|
||
),
|
||
cmd!(
|
||
"develop.autoSync",
|
||
"Auto Sync",
|
||
["Edit"],
|
||
Some("Cmd+Alt+Shift+A"),
|
||
"{on?: bool} (toggles when omitted) — edits to the active photo also change the other selected photos (only the settings that changed; not spot removal / red eye) → {on}",
|
||
always,
|
||
|s, p| {
|
||
s.auto_sync = p.get("on").and_then(Value::as_bool).unwrap_or(!s.auto_sync);
|
||
Ok(json!({"on": s.auto_sync}))
|
||
}
|
||
),
|
||
// ---- 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)| {
|
||
// a preset carries no Upright correction: the photo keeps its own, turned with
|
||
// it, or solves its own for another mode
|
||
let after = s.preset_look(id, &preset, amount).unwrap_or_else(|| preset.apply(&d, amount));
|
||
s.develop_op(id, after, &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(' ', "-"));
|
||
let mut preset = Preset::from_settings(&pid, name, group, &d, &groups);
|
||
// an Upright correction is this photo's own analysis: a preset keeps the mode
|
||
preset.settings = crate::upright_transfer::without_correction(preset.settings);
|
||
s.presets.push(preset);
|
||
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()
|
||
}),
|
||
cmd!("preset.rename", "Rename Preset", [], None, "{id, name}", always, |s, p| {
|
||
let c = "preset.rename";
|
||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing name"))?.to_string();
|
||
user_preset(s, p, c)?.name = name;
|
||
ok()
|
||
}),
|
||
cmd!("preset.move", "Move Preset to Group", [], None, "{id, group}", always, |s, p| {
|
||
let c = "preset.move";
|
||
let group = str_param(p, "group").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing group"))?.to_string();
|
||
user_preset(s, p, c)?.group = group;
|
||
ok()
|
||
}),
|
||
cmd!(
|
||
"preset.update",
|
||
"Update Preset with Current Settings",
|
||
[],
|
||
None,
|
||
"{id, groups?: [settingsGroup] (default: the groups the preset already sets)} — from the active photo",
|
||
has_active,
|
||
|s, p| {
|
||
let c = "preset.update";
|
||
let id = active(s, c)?;
|
||
let current = s.develop_of(id).unwrap_or_default().to_json();
|
||
let groups = groups_param(p);
|
||
let pr = user_preset(s, p, c)?;
|
||
pr.settings = crate::upright_transfer::without_correction(match groups {
|
||
Some(g) => lightcraft_develop::extract_groups(&serde_json::from_value(current).unwrap_or_default(), &g),
|
||
// the same keys as before, with the current values
|
||
None => {
|
||
let keys: Vec<String> = pr.settings.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
|
||
Value::Object(keys.into_iter().filter_map(|k| current.get(&k).cloned().map(|v| (k, v))).collect())
|
||
}
|
||
});
|
||
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!("version.rename", "Rename Version", [], None, "{index, name}", has_active, |s, p| {
|
||
let c = "version.rename";
|
||
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(c, "missing name"))?.to_string();
|
||
edit_version(s, p, c, "Rename Version", |v, _| v.name = name)
|
||
}),
|
||
cmd!("version.update", "Update Version with Current Settings", [], None, "{index}", has_active, |s, p| {
|
||
edit_version(s, p, "version.update", "Update Version", |v, current| v.settings = current)
|
||
}),
|
||
cmd!("history.clear", "Clear History", [], None, "{} — keeps the current settings as the only step", has_active, |s, _| {
|
||
let id = active(s, "history.clear")?;
|
||
let d = s.develop_of(id).unwrap_or_default();
|
||
let history = vec![lightcraft_catalog::HistoryStep { label: "Cleared History".into(), settings: d }];
|
||
s.commit("Clear History", Op::Batch { ops: vec![Op::SetHistory { id, history }, Op::SetHistoryCursor { id, cursor: None }] })?;
|
||
ok()
|
||
}),
|
||
cmd!(
|
||
"history.restore",
|
||
"Go to History Step",
|
||
[],
|
||
None,
|
||
"{index} — loads that step's settings and moves the History cursor there; later steps stay listed until a new edit",
|
||
has_active,
|
||
|s, p| {
|
||
let c = "history.restore";
|
||
let id = active(s, c)?;
|
||
let i = f64_req(p, "index", c)?;
|
||
if !(i.is_finite() && i >= 0.0) {
|
||
return Err(bad(c, "no such step"));
|
||
}
|
||
go_to_history_step(s, c, id, i as usize)
|
||
}
|
||
),
|
||
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
|
||
let c = "history.previous";
|
||
let id = active(s, c)?;
|
||
let i = s
|
||
.catalog
|
||
.photo(id)
|
||
.and_then(|ph| ph.history_index())
|
||
.and_then(|i| i.checked_sub(1))
|
||
.ok_or_else(|| bad(c, "at the first History step"))?;
|
||
go_to_history_step(s, c, id, i)
|
||
}),
|
||
cmd!("history.next", "Next History Step", [], None, "{} — steps the History cursor forward one step", has_history_after, |s, _| {
|
||
let c = "history.next";
|
||
let id = active(s, c)?;
|
||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||
let i = ph
|
||
.history_index()
|
||
.map(|i| i.saturating_add(1))
|
||
.filter(|i| *i < ph.history.len())
|
||
.ok_or_else(|| bad(c, "at the latest History step"))?;
|
||
go_to_history_step(s, c, id, i)
|
||
}),
|
||
// ---- 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()
|
||
}),
|
||
]
|
||
}
|
||
|
||
/// Load History step `i` of photo `id` and put the cursor there, without adding a step: the later
|
||
/// steps stay listed (one undo step).
|
||
fn go_to_history_step(s: &mut Session, c: &str, id: PhotoId, i: usize) -> Result<Value> {
|
||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||
let st = ph.history.get(i).cloned().ok_or_else(|| bad(c, "no such step"))?;
|
||
let cursor = (i.saturating_add(1) < ph.history.len()).then_some(i);
|
||
let label = format!("History: {}", st.label);
|
||
let edited = Some((s.clock)());
|
||
s.commit(
|
||
&label,
|
||
Op::Batch { ops: vec![Op::SetDevelop { id, settings: st.settings, label: label.clone(), edited }, Op::SetHistoryCursor { id, cursor }] },
|
||
)?;
|
||
ok()
|
||
}
|
||
|
||
fn has_history_before(s: &Session) -> std::result::Result<(), String> {
|
||
let at = s.active().and_then(|id| s.catalog.photo(id)).and_then(|p| p.history_index());
|
||
if at.is_some_and(|i| i > 0) { Ok(()) } else { Err("at the first History step".into()) }
|
||
}
|
||
|
||
fn has_history_after(s: &Session) -> std::result::Result<(), String> {
|
||
let photo = s.active().and_then(|id| s.catalog.photo(id));
|
||
if photo.is_some_and(|p| p.history_index().is_some_and(|i| i.saturating_add(1) < p.history.len())) {
|
||
Ok(())
|
||
} else {
|
||
Err("at the latest History step".into())
|
||
}
|
||
}
|