mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 16:31:32 -06:00
Merge main into M5.6 (memory) branch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
//! Commands that sample the developed image: Point Color samples and the targeted adjustment tool
|
||||
//! (`develop.targeted`: a vertical drag on the photo raises/lowers the tone-curve region or the
|
||||
//! colour-mixer bands under the pointer; the UI sends one call per drag step inside an interaction).
|
||||
//!
|
||||
//! Sampling renders a small proxy with every stage *after* the sampled one neutralized, so the
|
||||
//! picked colour is the one the adjustment will see (e.g. Point Color samples after the colour
|
||||
//! mixer, before vibrance, grading, vignette and curves).
|
||||
|
||||
use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020};
|
||||
use lightcraft_color::spline::MonotoneCurve;
|
||||
use lightcraft_color::transfer::srgb_to_linear;
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_develop::{DevelopSettings, MAX_POINT_COLORS, MIXER_BANDS, PointColor, ToneCurve, Treatment, controls};
|
||||
use lightcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, f64_req, has_active};
|
||||
use crate::media::SourceLevel;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Long edge of the proxy rendered for sampling.
|
||||
const PROBE_EDGE: usize = 384;
|
||||
|
||||
/// The sRGB-encoded colour (0..1) at normalized image point `(x, y)` of the active photo rendered
|
||||
/// with its settings changed by `neutral`, averaged over 3 × 3 proxy pixels.
|
||||
pub(crate) fn probe(s: &mut Session, c: &str, x: f64, y: f64, neutral: impl FnOnce(&mut DevelopSettings)) -> Result<[f32; 3]> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let src = s.source_now(id, SourceLevel::Thumb).map_err(|e| bad(c, e))?;
|
||||
let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
neutral(&mut d);
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(PROBE_EDGE, PROBE_EDGE);
|
||||
let plan = lightcraft_pipeline::plan(&src, &info, &d, &req);
|
||||
let q = plan.frame.norm_to_out(plan.w, plan.h).apply(Point::new(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
|
||||
let img = lightcraft_pipeline::render(&src, &info, &d, &req).image;
|
||||
let (cx, cy) = (q.x.floor() as isize, q.y.floor() as isize);
|
||||
let mut acc = [0.0f32; 3];
|
||||
let mut n = 0.0;
|
||||
for dy in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
let (px, py) = ((cx + dx).clamp(0, img.width as isize - 1) as usize, (cy + dy).clamp(0, img.height as isize - 1) as usize);
|
||||
let p = img.data[py * img.width + px];
|
||||
for k in 0..3 {
|
||||
acc[k] += p[k] as f32 / 255.0;
|
||||
}
|
||||
n += 1.0;
|
||||
}
|
||||
}
|
||||
Ok(acc.map(|v| v / n))
|
||||
}
|
||||
|
||||
/// sRGB-encoded → OkLCh (lightness, chroma, hue in radians) via linear Rec.2020.
|
||||
pub(crate) fn encoded_to_oklch(e: [f32; 3]) -> [f32; 3] {
|
||||
let lin = SRGB.to_space(&REC2020).apply_f32(e.map(srgb_to_linear));
|
||||
lab_to_lch(oklab_from_2020(lin))
|
||||
}
|
||||
|
||||
/// Everything after the colour mixer, neutralized (the colour Point Color sees).
|
||||
pub(crate) fn after_mixer_neutral(d: &mut DevelopSettings) {
|
||||
d.color.vibrance = 0.0;
|
||||
d.color.saturation = 0.0;
|
||||
d.point_colors.clear();
|
||||
d.treatment = Treatment::Color;
|
||||
d.grading = Default::default();
|
||||
d.vignette.amount = 0.0;
|
||||
d.grain.amount = 0.0;
|
||||
d.curve = Default::default();
|
||||
}
|
||||
|
||||
/// The targeted adjustment tool: the controls (and their new values) a vertical drag of `delta`
|
||||
/// at `(x, y)` changes, computed on `d` (applied by the caller).
|
||||
fn targeted(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "develop.targeted";
|
||||
let (x, y, delta) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?, f64_req(p, "delta", c)?);
|
||||
let target = p.get("target").and_then(Value::as_str).unwrap_or("curve").to_string();
|
||||
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 changed = serde_json::Map::new();
|
||||
fn nudge(changed: &mut serde_json::Map<String, Value>, d: &mut DevelopSettings, ctl: &str, by: f64) {
|
||||
let v = controls::get(d, ctl).unwrap_or(0.0) + by;
|
||||
controls::set(d, ctl, v);
|
||||
changed.insert(ctl.to_string(), json!(controls::get(d, ctl)));
|
||||
}
|
||||
let label;
|
||||
match target.as_str() {
|
||||
"curve" => {
|
||||
// the encoded value the tone curve sees there (curves and grain neutralized)
|
||||
let e = probe(s, c, x, y, |d| {
|
||||
d.curve = ToneCurve { refine_saturation: d.curve.refine_saturation, ..Default::default() };
|
||||
d.grain.amount = 0.0;
|
||||
})?;
|
||||
let channel = p.get("channel").and_then(Value::as_str).unwrap_or("parametric");
|
||||
label = "Tone Curve";
|
||||
if channel == "parametric" {
|
||||
let v = (0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]) as f64 * 100.0;
|
||||
let cv = &d.curve;
|
||||
let region = if v < cv.split_shadows {
|
||||
"curve.shadows"
|
||||
} else if v < cv.split_mid {
|
||||
"curve.darks"
|
||||
} else if v < cv.split_highlights {
|
||||
"curve.lights"
|
||||
} else {
|
||||
"curve.highlights"
|
||||
};
|
||||
nudge(&mut changed, &mut d, region, delta);
|
||||
} else {
|
||||
// point curve: move the point at the sampled input up/down (or add one there)
|
||||
let (pts, v) = match channel {
|
||||
"red" => (&mut d.curve.red, e[0]),
|
||||
"green" => (&mut d.curve.green, e[1]),
|
||||
"blue" => (&mut d.curve.blue, e[2]),
|
||||
_ => (&mut d.curve.master, 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]),
|
||||
};
|
||||
let v = v as f64;
|
||||
if pts.is_empty() {
|
||||
*pts = vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)];
|
||||
}
|
||||
let curve = MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>());
|
||||
let dy = delta / 255.0;
|
||||
match pts.iter().position(|q| (q.x - v).abs() < 0.03) {
|
||||
Some(i) => pts[i].y = (pts[i].y + dy).clamp(0.0, 1.0),
|
||||
None => {
|
||||
let i = pts.iter().position(|q| q.x > v).unwrap_or(pts.len());
|
||||
pts.insert(i, Point::new(v, (curve.eval(v) + dy).clamp(0.0, 1.0)));
|
||||
}
|
||||
}
|
||||
changed.insert(format!("curve.{channel}"), json!(pts.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
"hue" | "sat" | "lum" => {
|
||||
// the hue the colour mixer sees there (the mixer and everything after it neutralized)
|
||||
let e = probe(s, c, x, y, |d| {
|
||||
d.mixer = Default::default();
|
||||
d.bw_mix = Default::default();
|
||||
after_mixer_neutral(d);
|
||||
})?;
|
||||
let [_, _, h] = encoded_to_oklch(e);
|
||||
let w = lightcraft_pipeline::colorops::band_weights(h);
|
||||
let top = w.iter().cloned().fold(0.0f32, f32::max).max(1e-6);
|
||||
let bw = d.treatment == Treatment::Bw || d.profile.id == "lc.mono";
|
||||
label = if bw { "B&W Mix" } else { "Color Mixer" };
|
||||
for (i, band) in MIXER_BANDS.iter().enumerate() {
|
||||
let k = (w[i] / top) as f64;
|
||||
if k < 0.05 {
|
||||
continue;
|
||||
}
|
||||
let ctl = if bw { format!("bw.{band}") } else { format!("mixer.{band}.{target}") };
|
||||
nudge(&mut changed, &mut d, &ctl, delta * k);
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(c, "target must be curve, hue, sat or lum")),
|
||||
}
|
||||
s.set_develop(id, d, label)?;
|
||||
Ok(Value::Object(changed))
|
||||
}
|
||||
|
||||
fn index(p: &Value, c: &str) -> Result<usize> {
|
||||
Ok(f64_req(p, "index", c)? as usize)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"pointColor.pick",
|
||||
"Add Point Color Sample",
|
||||
[],
|
||||
None,
|
||||
"{x, y} normalized image coords — samples the colour there (max 8); returns {index, lum, chroma, hue}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "pointColor.pick";
|
||||
let (x, y) = (f64_req(p, "x", c)?, f64_req(p, "y", c)?);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
if s.develop_of(id).is_some_and(|d| d.point_colors.len() >= MAX_POINT_COLORS) {
|
||||
return Err(bad(c, format!("at most {MAX_POINT_COLORS} point colours")));
|
||||
}
|
||||
let [l, ch, h] = encoded_to_oklch(probe(s, c, x, y, after_mixer_neutral)?);
|
||||
let sample = PointColor { lum: l as f64, chroma: ch as f64, hue: (h as f64).to_degrees().rem_euclid(360.0), ..Default::default() };
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
d.point_colors.push(sample);
|
||||
let i = d.point_colors.len() - 1;
|
||||
s.set_develop(id, d, "Point Color")?;
|
||||
Ok(json!({"index": i, "lum": sample.lum, "chroma": sample.chroma, "hue": sample.hue}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"develop.targeted",
|
||||
"Targeted Adjustment",
|
||||
[],
|
||||
None,
|
||||
"{target: curve|hue|sat|lum, x, y (normalized image coords), delta (slider units; point curves: output levels 0..255), channel?: parametric|master|red|green|blue (curve)} — adjusts the curve region / colour-mixer bands under the point; returns the changed controls",
|
||||
has_active,
|
||||
targeted
|
||||
),
|
||||
cmd!("pointColor.delete", "Delete Point Color Sample", [], None, "{index}", has_active, |s, p| {
|
||||
let c = "pointColor.delete";
|
||||
let i = index(p, c)?;
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut d = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if i >= d.point_colors.len() {
|
||||
return Err(bad(c, "no such sample"));
|
||||
}
|
||||
d.point_colors.remove(i);
|
||||
s.set_develop(id, d, "Delete Point Color")?;
|
||||
Ok(Value::Null)
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -330,6 +330,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
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.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default();
|
||||
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",
|
||||
|
||||
@@ -292,25 +292,63 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
// ---- albums
|
||||
cmd!("album.create", "New Album", ["File"], None, "{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, smart: None } },
|
||||
)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}),
|
||||
cmd!(
|
||||
"album.create",
|
||||
"New Album",
|
||||
["File"],
|
||||
Some("Cmd+Shift+N"),
|
||||
"{name, parent?: folderId, folder?: bool, addSelected?: bool}",
|
||||
"album.createSmart",
|
||||
"New Smart Album…",
|
||||
[],
|
||||
None,
|
||||
"{name, rules?: partial Filter (rating, ratingOp, flag, label, kind, edited, keyword, camera, lens, dateFrom, dateTo, date, text, album), parent?: folderId} — without `rules`, saves the current view (source + filter)",
|
||||
always,
|
||||
|s, p| {
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string();
|
||||
let name = str_param(p, "name").unwrap_or("Smart Album").trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad("album.create", "empty name"));
|
||||
return Err(bad("album.createSmart", "empty name"));
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let rules = match p.get("rules") {
|
||||
Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?,
|
||||
None => view_rules(s),
|
||||
};
|
||||
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}))
|
||||
let album = Album { parent, smart: Some(Box::new(rules)), ..Album::new(id, name) };
|
||||
s.commit("New Smart Album", Op::AddAlbum { album })?;
|
||||
Ok(json!({"id": id.0, "count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"album.setRules",
|
||||
"Edit Smart Album",
|
||||
[],
|
||||
None,
|
||||
"{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}",
|
||||
always,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.setRules")?;
|
||||
let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?;
|
||||
let rules = if bool_or(p, "fromView", false) {
|
||||
view_rules(s)
|
||||
} else {
|
||||
let base = if bool_or(p, "replace", false) { Default::default() } else { cur };
|
||||
merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?
|
||||
};
|
||||
s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?;
|
||||
Ok(json!({"count": s.catalog.album_count(id)}))
|
||||
}
|
||||
),
|
||||
cmd!("album.rename", "Rename Album", [], None, "{id, name}", always, |s, p| {
|
||||
@@ -337,6 +375,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
}
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
for t in targets {
|
||||
@@ -353,6 +394,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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"))?;
|
||||
if al.is_smart() {
|
||||
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
|
||||
}
|
||||
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
|
||||
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
|
||||
ok()
|
||||
@@ -452,6 +496,51 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// `base` with a partial Filter (JSON) merged on top.
|
||||
fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result<lightcraft_catalog::Filter> {
|
||||
let mut v = serde_json::to_value(base).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, patch);
|
||||
serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))
|
||||
}
|
||||
|
||||
/// The current view (source + filter) as smart-album rules. Viewing a smart album starts from
|
||||
/// its rules with the filter bar's settings on top.
|
||||
fn view_rules(s: &Session) -> lightcraft_catalog::Filter {
|
||||
use lightcraft_catalog::Filter;
|
||||
let smart = match s.source {
|
||||
LibrarySource::Album(a) => s.catalog.album(a).and_then(|a| a.smart.as_deref().cloned()),
|
||||
_ => None,
|
||||
};
|
||||
match smart {
|
||||
Some(base) => {
|
||||
// overlay the fields the filter bar changed
|
||||
let cur = serde_json::to_value(&s.filter).unwrap_or_default();
|
||||
let def = serde_json::to_value(Filter::default()).unwrap_or_default();
|
||||
let mut patch = serde_json::Map::new();
|
||||
if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) {
|
||||
for (k, v) in c {
|
||||
if d.get(k) != Some(v) {
|
||||
patch.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
merge_rules(&base, &Value::Object(patch), "").unwrap_or(base)
|
||||
}
|
||||
None => {
|
||||
let mut f = s.source.to_filter(&s.filter, &s.catalog);
|
||||
f.deleted = false;
|
||||
f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The current view (source + filter) as smart-album rules (see `album.createSmart`).
|
||||
pub fn view_rules(&self) -> lightcraft_catalog::Filter {
|
||||
view_rules(self)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_library(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.library.is_some() { Ok(()) } else { Err("no library is open (in-memory session)".into()) }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Masking and Remove (spot) commands on the active photo.
|
||||
|
||||
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, Spot, SpotMode};
|
||||
use lightcraft_develop::{BrushStroke, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, RedEye, Spot, SpotMode};
|
||||
use lightcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -292,6 +292,69 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.set_develop(id, dd, "Delete Spot")?;
|
||||
ok()
|
||||
}),
|
||||
// ---- Red eye / pet eye
|
||||
cmd!(
|
||||
"redeye.add",
|
||||
"Add Red Eye Correction",
|
||||
[],
|
||||
None,
|
||||
"{center: [x,y] normalized, rx, ry: radii as fractions of the long edge, pet?: bool, pupilSize?: 0..100, darken?: 0..100} — the pupil inside is found automatically; returns {index}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "redeye.add";
|
||||
let center = point(p, "center").ok_or_else(|| bad(c, "missing center"))?;
|
||||
let d = RedEye::default();
|
||||
let eye = RedEye {
|
||||
center,
|
||||
rx: f64_or(p, "rx", d.rx).clamp(1e-4, 0.5),
|
||||
ry: f64_or(p, "ry", d.ry).clamp(1e-4, 0.5),
|
||||
pupil_size: f64_or(p, "pupilSize", d.pupil_size).clamp(0.0, 100.0),
|
||||
darken: f64_or(p, "darken", d.darken).clamp(0.0, 100.0),
|
||||
pet: bool_or(p, "pet", false),
|
||||
catchlight: None,
|
||||
};
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
dd.red_eye.push(eye);
|
||||
let n = dd.red_eye.len();
|
||||
s.set_develop(id, dd, if eye.pet { "Pet Eye" } else { "Red Eye" })?;
|
||||
Ok(json!({"index": n - 1}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"redeye.catchlight",
|
||||
"Pet Eye Catchlight",
|
||||
[],
|
||||
None,
|
||||
"{index, on?: bool (default true), offset?: [dx, dy] from the pupil centre in pupil radii (default [-0.35, -0.35])}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "redeye.catchlight";
|
||||
let i = super::f64_req(p, "index", c)? as usize;
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?;
|
||||
if !eye.pet {
|
||||
return Err(bad(c, "catchlights are for pet eyes"));
|
||||
}
|
||||
let off = point(p, "offset").or(eye.catchlight).unwrap_or(Point::new(-0.35, -0.35));
|
||||
eye.catchlight = bool_or(p, "on", true).then(|| Point::new(off.x.clamp(-1.0, 1.0), off.y.clamp(-1.0, 1.0)));
|
||||
s.set_develop(id, dd, "Catchlight")?;
|
||||
ok()
|
||||
}
|
||||
),
|
||||
cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| {
|
||||
let c = "redeye.delete";
|
||||
let i = super::f64_req(p, "index", c)? as usize;
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if i >= dd.red_eye.len() {
|
||||
return Err(bad(c, "no such eye"));
|
||||
}
|
||||
dd.red_eye.remove(i);
|
||||
s.set_develop(id, dd, "Delete Red Eye")?;
|
||||
ok()
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`.
|
||||
//!
|
||||
//! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with
|
||||
//! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames
|
||||
//! and (with `previewPath`) writes the rendered preview as PNG instead of adding a photo.
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, bool_or, cmd, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn can_merge(s: &Session) -> std::result::Result<(), String> {
|
||||
if cfg!(target_arch = "wasm32") && s.media.file_bytes.is_none() {
|
||||
return Err("Photo Merge needs access to the photo files".into());
|
||||
}
|
||||
// the photo count is checked when run (an explicit `ids` parameter may name them)
|
||||
if s.catalog.photos().nth(1).is_none() { Err("the library has fewer than 2 photos".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let (kind, finish) = crate::merge::parse(id, p)?;
|
||||
let ids: Vec<PhotoId> = s.targets(p);
|
||||
if !bool_or(p, "preview", false) {
|
||||
return s.merge_now(kind, finish, &ids);
|
||||
}
|
||||
let job = s.plan_merge(kind, finish, &ids, true)?;
|
||||
let out = job.run(&|_, _| true).map_err(|e| bad(id, e))?;
|
||||
let mut info = out.info;
|
||||
if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) {
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
|
||||
.map_err(|e| bad(id, e.to_string()))?;
|
||||
std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {e}")))?;
|
||||
info["previewPath"] = json!(path);
|
||||
}
|
||||
if let Some(img) = &out.preview {
|
||||
info["previewSize"] = json!([img.width, img.height]);
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"merge.hdr",
|
||||
"HDR Merge",
|
||||
[],
|
||||
None,
|
||||
"{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}",
|
||||
can_merge,
|
||||
|s, p| run(s, "merge.hdr", p)
|
||||
),
|
||||
cmd!(
|
||||
"merge.panorama",
|
||||
"Panorama Merge",
|
||||
[],
|
||||
None,
|
||||
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}",
|
||||
can_merge,
|
||||
|s, p| run(s, "merge.panorama", p)
|
||||
),
|
||||
cmd!(
|
||||
"merge.hdrPanorama",
|
||||
"HDR Panorama Merge",
|
||||
[],
|
||||
None,
|
||||
"{ids?, bracket=0 (auto from EXIF), align, deghost, projection, boundaryWarp, autoCrop, fillEdges, autoSettings, stack, preview, previewPath?}",
|
||||
can_merge,
|
||||
|s, p| run(s, "merge.hdrPanorama", p)
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -4,10 +4,13 @@
|
||||
//! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`,
|
||||
//! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…).
|
||||
|
||||
mod color;
|
||||
mod develop;
|
||||
mod edit;
|
||||
mod library;
|
||||
mod masks;
|
||||
mod merge;
|
||||
mod organize;
|
||||
mod preset_files;
|
||||
mod query;
|
||||
mod xmp;
|
||||
@@ -101,7 +104,10 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(edit::specs());
|
||||
v.extend(library::specs());
|
||||
v.extend(develop::specs());
|
||||
v.extend(color::specs());
|
||||
v.extend(masks::specs());
|
||||
v.extend(organize::specs());
|
||||
v.extend(merge::specs());
|
||||
v.extend(query::specs());
|
||||
v.extend(xmp::specs());
|
||||
v.extend(preset_files::specs());
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture
|
||||
//! time) and virtual copies.
|
||||
|
||||
use lightcraft_catalog::{HistoryStep, Op, PhotoId, Stack, StackId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
/// Stacks containing any of `targets`, in first-seen order.
|
||||
fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec<StackId> {
|
||||
let mut v = Vec::new();
|
||||
for t in targets {
|
||||
if let Some(st) = s.catalog.stack_of(*t)
|
||||
&& !v.contains(&st.id)
|
||||
{
|
||||
v.push(st.id);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn in_stack(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_stacks(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.catalog.stacks().next().is_none() { Err("no stacks".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn commit_ops(s: &mut Session, label: &str, ops: Vec<Op>) -> Result<usize> {
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(label, Op::Batch { ops })?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// After collapsing, keep the active photo visible (move it to its stack's top).
|
||||
fn fix_active(s: &mut Session) {
|
||||
let vis = s.visible_cloned();
|
||||
if let Some(a) = s.selection.active
|
||||
&& !vis.contains(&a)
|
||||
&& let Some(st) = s.catalog.stack_of(a)
|
||||
{
|
||||
let top = vis.iter().copied().find(|p| st.photos.contains(p)).unwrap_or(st.top());
|
||||
s.selection = Selection::single(top);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_collapsed(s: &mut Session, stacks: Vec<StackId>, collapsed: bool, label: &str) -> Result<Value> {
|
||||
let ops = s.catalog.set_collapsed_ops(&stacks, collapsed);
|
||||
let n = commit_ops(s, label, ops)?;
|
||||
if collapsed {
|
||||
fix_active(s);
|
||||
}
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// "Copy 3" → 3.
|
||||
fn copy_number(name: Option<&str>) -> u32 {
|
||||
name.and_then(|n| n.strip_prefix("Copy ")).and_then(|n| n.trim().parse().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Create one virtual copy of `src`: a new photo sharing its file, with the same settings and
|
||||
/// metadata, added to the same albums and stacked right after it (the stack is expanded so the
|
||||
/// copy is visible).
|
||||
fn virtual_copy(s: &mut Session, src: PhotoId) -> Result<Option<PhotoId>> {
|
||||
let Some(orig) = s.catalog.photo(src).cloned() else { return Ok(None) };
|
||||
let master = orig.copy_of.unwrap_or(src);
|
||||
let n = 1 + s.catalog.photos().filter(|p| p.copy_of == Some(master)).map(|p| copy_number(p.copy_name.as_deref())).max().unwrap_or(0);
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let mut c = (*orig).clone();
|
||||
c.id = id;
|
||||
c.copy_of = Some(master);
|
||||
c.copy_name = Some(format!("Copy {n}"));
|
||||
c.versions.clear();
|
||||
c.history = vec![HistoryStep { label: "Virtual Copy".into(), settings: c.develop.clone() }];
|
||||
c.deleted = false;
|
||||
let mut ops = vec![Op::AddPhoto { photo: Box::new(c) }];
|
||||
for a in s.catalog.albums().filter(|a| !a.is_smart() && a.photos.contains(&src)) {
|
||||
let mut photos = a.photos.clone();
|
||||
let at = photos.iter().position(|p| *p == src).map_or(photos.len(), |i| i + 1);
|
||||
photos.insert(at, id);
|
||||
ops.push(Op::SetAlbumPhotos { id: a.id, photos });
|
||||
}
|
||||
match s.catalog.stack_of(src).cloned() {
|
||||
Some(st) => {
|
||||
let mut photos = st.photos.clone();
|
||||
let at = st.position(src).map_or(photos.len(), |i| i + 1);
|
||||
photos.insert(at, id);
|
||||
ops.push(Op::SetStack { id: st.id, photos, collapsed: false });
|
||||
}
|
||||
None => {
|
||||
let sid = s.catalog.alloc_stack_id();
|
||||
ops.push(Op::AddStack { stack: Stack { id: sid, photos: vec![src, id], collapsed: false } });
|
||||
}
|
||||
}
|
||||
s.commit("Create Virtual Copy", Op::Batch { ops })?;
|
||||
Ok(Some(id))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"photo.virtualCopy",
|
||||
"Create Virtual Copy",
|
||||
["Photo"],
|
||||
Some("Cmd+'"),
|
||||
"{ids?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named \"Copy N\"",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let mut made = Vec::new();
|
||||
for t in targets {
|
||||
if let Some(id) = virtual_copy(s, t)? {
|
||||
made.push(id);
|
||||
}
|
||||
}
|
||||
s.merge_undo(made.len(), "Create Virtual Copy");
|
||||
if let Some(last) = made.last() {
|
||||
s.selection = Selection { ids: made.clone(), active: Some(*last) };
|
||||
}
|
||||
Ok(json!({"ids": made.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"stack.group",
|
||||
"Group into Stack",
|
||||
["Photo", "Stack"],
|
||||
Some("Cmd+G"),
|
||||
"{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let top = p
|
||||
.get("top")
|
||||
.and_then(Value::as_u64)
|
||||
.map(PhotoId)
|
||||
.or(s.selection.active.filter(|a| targets.contains(a)))
|
||||
.or(targets.first().copied())
|
||||
.ok_or_else(|| bad("stack.group", "no photos"))?;
|
||||
let op = s
|
||||
.catalog
|
||||
.group_ops(top, &targets, bool_or(p, "collapsed", true))
|
||||
.ok_or_else(|| bad("stack.group", "select at least two photos"))?;
|
||||
s.commit("Group into Stack", op)?;
|
||||
let st = s.catalog.stack_of(top).map(|st| (st.id.0, st.photos.len())).unwrap_or_default();
|
||||
s.selection = Selection::single(top);
|
||||
Ok(json!({"stack": st.0, "count": st.1}))
|
||||
}
|
||||
),
|
||||
cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| {
|
||||
let ops = s.catalog.ungroup_ops(&s.targets(p));
|
||||
Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| {
|
||||
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p));
|
||||
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?;
|
||||
let op = s.catalog.set_top_ops(id);
|
||||
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
|
||||
let stacks = stacks_of(s, &s.targets(p));
|
||||
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
|
||||
let collapsed = bool_or(p, "collapsed", !first);
|
||||
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
|
||||
}),
|
||||
cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| {
|
||||
let all: Vec<StackId> = s.catalog.stacks().map(|st| st.id).collect();
|
||||
set_collapsed(s, all, false, "Expand All Stacks")
|
||||
}),
|
||||
cmd!("stack.collapseAll", "Collapse All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| {
|
||||
let all: Vec<StackId> = s.catalog.stacks().map(|st| st.id).collect();
|
||||
set_collapsed(s, all, true, "Collapse All Stacks")
|
||||
}),
|
||||
cmd!(
|
||||
"stack.auto",
|
||||
"Auto-Stack by Capture Time",
|
||||
[],
|
||||
None,
|
||||
"{gap?: seconds between consecutive captures (default 60), ids? (default: the selection if several photos, else the current view), preview?: bool} → {stacks, photos}",
|
||||
always,
|
||||
|s, p| {
|
||||
let gap = f64_or(p, "gap", 60.0).max(0.0);
|
||||
let explicit = p.get("ids").is_some();
|
||||
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let groups = s.catalog.auto_stack_groups(&pool, gap);
|
||||
let photos: usize = groups.iter().map(Vec::len).sum();
|
||||
if bool_or(p, "preview", false) {
|
||||
return Ok(json!({"stacks": groups.len(), "photos": photos}));
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
for g in &groups {
|
||||
ops.extend(s.catalog.group_ops(g[0], g, true));
|
||||
}
|
||||
commit_ops(s, "Auto-Stack", ops)?;
|
||||
fix_active(s);
|
||||
Ok(json!({"stacks": groups.len(), "photos": photos}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -24,18 +24,26 @@ pub fn photo_summary(p: &Photo) -> Value {
|
||||
"keywords": p.meta.keywords,
|
||||
"camera": p.meta.camera,
|
||||
"deleted": p.deleted,
|
||||
"copyOf": p.copy_of.map(|c| c.0),
|
||||
"copyName": p.copy_name,
|
||||
})
|
||||
}
|
||||
|
||||
fn album_json(a: &Album, all: &[Album]) -> Value {
|
||||
json!({
|
||||
fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Value {
|
||||
let mut v = json!({
|
||||
"id": a.id.0,
|
||||
"name": a.name,
|
||||
"folder": a.folder,
|
||||
"count": a.photos.len(),
|
||||
"count": cat.album_count(a.id),
|
||||
"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<_>>(),
|
||||
})
|
||||
"children": all.iter().filter(|c| c.parent == Some(a.id)).map(|c| album_json(c, all, cat)).collect::<Vec<_>>(),
|
||||
});
|
||||
if let Some(rules) = &a.smart {
|
||||
v["smart"] = json!(true);
|
||||
v["rules"] = serde_json::to_value(rules).unwrap_or_default();
|
||||
v["rulesText"] = json!(rules.describe());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
@@ -88,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
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()))
|
||||
Ok(Value::Array(all.iter().filter(|a| a.parent.is_none()).map(|a| album_json(a, &all, &s.catalog)).collect()))
|
||||
}),
|
||||
cmd!(query "photo.inspect", "Inspect Photo", [], None, "{id?}", always, |s, p| {
|
||||
let id = photo_arg(s, p, "photo.inspect")?;
|
||||
@@ -96,6 +104,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<_>>());
|
||||
if let Some(st) = s.catalog.stack_of(id) {
|
||||
v["stack"] = json!({
|
||||
"id": st.id.0,
|
||||
"photos": st.photos.iter().map(|p| p.0).collect::<Vec<_>>(),
|
||||
"position": st.position(id),
|
||||
"collapsed": st.collapsed,
|
||||
});
|
||||
}
|
||||
Ok(v)
|
||||
}),
|
||||
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {
|
||||
@@ -114,6 +130,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
v["value"] = json!(controls::get(&d, c.id));
|
||||
v
|
||||
})
|
||||
// indexed controls of the list elements that exist (`pointColor.0.hueShift`, …)
|
||||
.chain(controls::indexed_instances(&d).into_iter().filter(|(_, c)| sec.is_none_or(|x| x == c.section)).map(|(id, c)| {
|
||||
let mut v = serde_json::to_value(c).unwrap_or_default();
|
||||
v["value"] = json!(controls::get(&d, &id));
|
||||
v["id"] = json!(id);
|
||||
v
|
||||
}))
|
||||
.collect(),
|
||||
))
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user