mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 13:07:43 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user