Merge main into worktree-agent-ab72a7623afb95196

# Conflicts:
#	crates/engine/src/import.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/src/headless.rs
#	crates/ui-egui/src/panels/dialogs.rs
#	crates/ui-egui/src/panels/grid.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas
2026-10-01 13:33:29 -04:00
60 changed files with 4042 additions and 383 deletions
+6 -1
View File
@@ -128,7 +128,12 @@ pub fn specs() -> Vec<CommandSpec> {
.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() };
// 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 => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() },
};
s.develop_op(id, fresh, "Reset")
})
.collect();
+2
View File
@@ -49,6 +49,7 @@ fn step(s: &mut Session, d: isize) -> Result<Value> {
s.end_interaction()?;
s.selection = Selection::single(vis[next]);
s.active_mask = None;
s.active_spot = None;
Ok(json!({"active": vis[next].0}))
}
@@ -193,6 +194,7 @@ pub fn specs() -> Vec<CommandSpec> {
other => return Err(bad("library.select", format!("unknown mode `{other}`"))),
}
s.active_mask = None;
s.active_spot = None;
Ok(json!({"selected": s.selection.ids.len()}))
}),
cmd!("library.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, |s, _| {
+125 -13
View File
@@ -251,7 +251,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Remove Spot",
[],
None,
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy]}",
"{mode?: remove|heal|clone, points: [[x,y],…], size?: fraction of long edge, feather?, opacity?, source?: [dx,dy] (default: the best match nearby)} — selects the new spot; returns {index}",
has_active,
|s, p| {
let mode: SpotMode =
@@ -265,31 +265,113 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("spot.add", "missing points"));
}
let d = Spot::default();
let spot = Spot {
let mut 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),
size: f64_or(p, "size", d.size).clamp(SPOT_SIZE.0, SPOT_SIZE.1),
feather: f64_or(p, "feather", d.feather).clamp(0.0, 100.0),
opacity: f64_or(p, "opacity", d.opacity).clamp(0.0, 100.0),
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();
if spot.source_offset.is_none() {
// resolve the automatic source now: it gets a pin and stays put at every size
spot.source_offset = pick_source(s, id, &dd, &spot, None);
}
dd.spots.push(spot);
let n = dd.spots.len();
s.set_develop(id, dd, "Remove")?;
s.active_spot = Some(n - 1);
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"));
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
let n = spots_len(s);
s.active_spot = match p.get("index").and_then(Value::as_u64) {
Some(i) if (i as usize) < n => Some(i as usize),
Some(_) => return Err(bad("spot.select", "no such spot")),
None => None,
};
Ok(json!({"activeSpot": s.active_spot}))
}),
cmd!(
"spot.update",
"Edit Spot",
[],
None,
"{index? (the selected spot), move?: [dx,dy] (the target, normalized), source?: [dx,dy] (offset from the target), moveSource?: [dx,dy], size?, feather?, opacity?, mode?}",
has_active,
|s, p| {
let c = "spot.update";
let i = spot_index(s, p, c)?;
let mode: Option<SpotMode> = match p.get("mode") {
Some(m) => Some(serde_json::from_value(m.clone()).map_err(|e| bad(c, e.to_string()))?),
None => None,
};
spots_edit(s, c, "Edit Spot", |spots| {
let sp = &mut spots[i];
if let Some(d) = point(p, "move") {
sp.points.iter_mut().for_each(|q| *q = Point::new(q.x + d.x, q.y + d.y));
// the source moves along (its offset is relative to the target)
}
if let Some(o) = point(p, "source") {
sp.source_offset = Some(o);
}
if let Some(d) = point(p, "moveSource") {
let o = sp.source_offset.unwrap_or(Point::new(0.0, 0.0));
sp.source_offset = Some(Point::new(o.x + d.x, o.y + d.y));
}
if let Some(v) = p.get("size").and_then(Value::as_f64) {
sp.size = v.clamp(SPOT_SIZE.0, SPOT_SIZE.1);
}
if let Some(v) = p.get("feather").and_then(Value::as_f64) {
sp.feather = v.clamp(0.0, 100.0);
}
if let Some(v) = p.get("opacity").and_then(Value::as_f64) {
sp.opacity = v.clamp(0.0, 100.0);
}
if let Some(m) = mode {
sp.mode = m;
}
Ok(())
})?;
Ok(json!({"index": i}))
}
dd.spots.remove(i);
s.set_develop(id, dd, "Delete Spot")?;
),
cmd!(
"spot.refreshSource",
"Refresh Source",
[],
None,
"{index? (the selected spot)} — picks the next-best source away from the current one",
has_active,
|s, p| {
let c = "spot.refreshSource";
let i = spot_index(s, p, c)?;
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let dd = (*s.develop_of(id).unwrap_or_default()).clone();
let spot = dd.spots[i].clone();
let next = pick_source(s, id, &dd, &spot, spot.source_offset).ok_or_else(|| bad(c, "no other source fits in the photo"))?;
spots_edit(s, c, "Refresh Source", |spots| {
spots[i].source_offset = Some(next);
Ok(())
})?;
Ok(json!({"index": i, "source": [next.x, next.y]}))
}
),
cmd!("spot.delete", "Delete Spot", [], None, "{index? (the selected spot)}", has_active, |s, p| {
let c = "spot.delete";
let i = spot_index(s, p, c)?;
spots_edit(s, c, "Delete Spot", |spots| {
spots.remove(i);
Ok(())
})?;
s.active_spot = match s.active_spot {
Some(a) if a == i => None,
Some(a) if a > i => Some(a - 1),
a => a,
};
ok()
}),
// ---- Red eye / pet eye
@@ -358,6 +440,36 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Spot radius limits (fraction of the long edge).
pub const SPOT_SIZE: (f64, f64) = (0.001, 0.25);
fn spots_len(s: &Session) -> usize {
s.active().and_then(|id| s.develop_of(id)).map(|d| d.spots.len()).unwrap_or(0)
}
/// `index` from the params, else the selected spot.
fn spot_index(s: &Session, p: &Value, c: &str) -> Result<usize> {
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
if i >= spots_len(s) {
return Err(bad(c, "no such spot"));
}
Ok(i)
}
fn spots_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Spot>) -> Result<()>) -> Result<()> {
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
f(&mut d.spots)?;
s.set_develop(id, d, label)
}
/// An automatic source for `spot` on photo `id` (a small proxy of the photo, framed by `d`).
fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::DevelopSettings, spot: &Spot, avoid: Option<Point>) -> Option<Point> {
let src = s.source_now(id, crate::media::SourceLevel::Thumb).ok()?;
let info = crate::media::source_info(s.catalog.photo(id)?);
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
}
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);
+2
View File
@@ -13,6 +13,7 @@ mod manage;
mod masks;
mod merge;
mod organize;
mod prefs;
mod preset_files;
mod query;
mod xmp;
@@ -115,6 +116,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(query::specs());
v.extend(xmp::specs());
v.extend(preset_files::specs());
v.extend(prefs::specs());
v
})
}
+91
View File
@@ -0,0 +1,91 @@
//! Library preferences (Settings → Import / Performance): develop defaults applied on import and
//! the thumbnail cache budget. Saved in the library's `prefs.json` (see [`crate::library`]).
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, cmd};
use crate::import::{CameraDefault, ImportDefaults};
use crate::{Result, Session};
const ID: &str = "library.preferences";
fn current(s: &Session) -> Value {
json!({
"xmp": s.xmp,
"import": s.import_defaults,
"cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) },
"persistent": s.library.is_some(),
})
}
/// `"default"`, `""` or null = no preset; anything else must name an existing preset.
fn preset_ref(s: &Session, v: &Value) -> Result<Option<String>> {
match v {
Value::Null => Ok(None),
Value::String(x) if x.is_empty() || x == "default" => Ok(None),
Value::String(x) if s.presets.iter().any(|p| p.id == *x) => Ok(Some(x.clone())),
Value::String(x) => Err(bad(ID, format!("unknown preset `{x}` (see preset.list)"))),
other => Err(bad(ID, format!("preset must be a preset id or \"default\", not {other}"))),
}
}
fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
let mut d: ImportDefaults = s.import_defaults.clone();
let mut changed = false;
if let Some(i) = p.get("import") {
if let Some(v) = i.get("rawPreset") {
d.raw_preset = preset_ref(s, v)?;
}
if let Some(v) = i.get("otherPreset") {
d.other_preset = preset_ref(s, v)?;
}
if let Some(v) = i.get("perCamera").and_then(Value::as_bool) {
d.per_camera = v;
}
if let Some(a) = i.get("cameras").and_then(Value::as_array) {
d.cameras.clear();
for c in a {
let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string();
if camera.is_empty() {
return Err(bad(ID, "each camera default needs `camera` (make + model)"));
}
let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?;
d.cameras.push(CameraDefault { camera, preset });
}
}
changed = true;
}
// set or clear one camera's default: {camera: {camera: "Make Model", preset: id|null}}
if let Some(c) = p.get("camera") {
let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string();
if camera.is_empty() {
return Err(bad(ID, "`camera.camera` (make + model) is required"));
}
let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?;
d.cameras.retain(|x| !x.camera.eq_ignore_ascii_case(&camera));
if c.get("remove").and_then(Value::as_bool) != Some(true) {
d.cameras.push(CameraDefault { camera, preset });
}
changed = true;
}
s.import_defaults = d;
if let Some(mb) = p.get("cacheMb") {
let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?;
s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?;
} else if changed {
s.save_prefs()?;
}
Ok(current(s))
}
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"library.preferences",
"Library Preferences",
[],
None,
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}]}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}",
always,
prefs
)]
}
+1
View File
@@ -89,6 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
"selection": s.selection,
"visibleCount": n,
"activeMask": s.active_mask,
"activeSpot": s.active_spot,
"undo": s.undo.last().map(|u| u.label.clone()),
"redo": s.redo.last().map(|u| u.label.clone()),
"clipboard": s.clipboard.is_some(),