lightcraft/crates/engine/src/lib.rs

1086 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The LightCraft engine façade.
//!
//! Every user-visible action is a command with a stable id (`photo.rate`, `develop.set`,
//! `album.create`, `mask.add`…) and JSON parameters. The egui UI, the CLI, the control channel and
//! the MCP server all go through [`Session::execute`].
//!
//! State: a [`Catalog`] (mutated only by ops, so every change is undoable and journaled), the
//! library view (filter/sort/source), the selection, the develop clipboard, presets, and caches
//! of decoded source proxies. Rendering is done by [`RenderJob`]s that are `Send` so frontends can
//! run them off the UI thread.
#![forbid(unsafe_code)]
// Model/cache types remain available without linking the optional inference crate.
#[cfg(not(feature = "denoise"))]
extern crate lightcraft_denoise_core as lightcraft_denoise;
pub mod activity;
#[cfg(not(target_arch = "wasm32"))]
pub mod apple_photos;
pub mod availability;
pub(crate) mod camera_preview;
pub mod camera_profiles;
pub mod cmd;
pub mod config;
pub mod contact_sheet;
pub mod crs;
pub mod crs_masks;
pub mod demo;
pub mod denoise;
pub mod devices;
pub mod display;
pub mod export;
pub mod face_download;
#[cfg(not(target_arch = "wasm32"))]
mod faces_index;
#[cfg(not(target_arch = "wasm32"))]
mod faces_worker;
pub mod files;
pub mod fonts;
pub mod guard;
pub mod import;
mod import_move;
pub mod lens_profiles;
pub mod library;
mod lightroom_archive;
pub mod lightroom_catalog;
pub mod lightroom_folder;
pub mod lightroom_job;
mod lightroom_sqlite;
pub mod logging;
pub mod media;
pub mod memory;
pub mod merge;
mod model_download;
pub mod originals;
pub mod preset_import;
pub mod preset_luminar;
pub mod presets;
pub mod proof;
pub mod rename;
pub mod segment;
pub mod sidecar;
pub mod smart;
pub mod sync;
mod view;
pub mod walk;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use fonts::{CRAFT_FONTS, CraftFont};
use lightcraft_catalog::{Catalog, Filter, Op, PhotoId, Sort};
use lightcraft_develop::DevelopSettings;
pub use media::{RenderJob, SourceLevel};
use serde_json::Value;
pub use view::{Browse, FilterChip, LibrarySource, Selection, SelectionState, filter_chips};
pub use {lightcraft_catalog as catalog, lightcraft_develop as develop, lightcraft_gpu as gpu, lightcraft_pipeline as pipeline};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
#[error("invalid parameters for `{cmd}`: {msg}")]
BadParams { cmd: String, msg: String },
#[error("{0}")]
Catalog(#[from] lightcraft_catalog::CatalogError),
/// The command's change is applied (in memory, undoable) but its journal records could not
/// be written. They stay queued and are written by the next successful save.
#[error("saved in memory but not written to disk: {0}; LightCraft will retry")]
NotSaved(String),
/// Another process (the app, `lightcraft-cli`, another computer) has the library open.
#[error("{0}")]
LibraryInUse(String),
/// The folder (or file) asked for is a Lightroom Classic catalog or part of one, not a
/// LightCraft library (issue #891): nothing was created or opened. `catalog`: the `.lrcat`
/// to import with `library.importLightroom`, when one was found.
#[error("{} is a Lightroom Classic catalog, not a LightCraft library; import it into a LightCraft library with File ▸ Import Lightroom Catalog…{}", path.display(), catalog.as_ref().map_or(String::new(), |c| format!(" ({})", c.display())))]
LightroomCatalog { path: std::path::PathBuf, catalog: Option<std::path::PathBuf> },
#[error("{0}")]
Other(String),
}
pub type Result<T> = std::result::Result<T, EngineError>;
/// One undo step: the inverse op and a label.
#[derive(Clone, Debug)]
pub struct UndoEntry {
pub label: String,
pub op: Op,
/// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder
/// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. An
/// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder).
pub folder: Option<FolderMove>,
/// Inverses of logged path corrections from a partially failed history step.
pub(crate) retry_inverse: Vec<Op>,
/// Sidecars still separated from their photo after a failed move or rollback.
pub(crate) retry_sidecars: Vec<rename::SidecarMove>,
}
/// The one photo an undo step changes, when it changes exactly one through its edit, rating, flag,
/// label, metadata or versions (`depth` bounds nested batches).
fn single_photo(op: &Op, depth: usize) -> Option<PhotoId> {
match op {
Op::SetDevelop { id, .. }
| Op::SetRating { id, .. }
| Op::SetFlag { id, .. }
| Op::SetLabel { id, .. }
| Op::SetMeta { id, .. }
| Op::SetVersions { id, .. }
| Op::SetHistory { id, .. }
| Op::PushHistory { id, .. } => Some(*id),
Op::Batch { ops } if depth < 8 => {
let mut ids = ops.iter().map(|o| single_photo(o, depth + 1));
let first = ids.next()??;
ids.all(|id| id == Some(first)).then_some(first)
}
_ => None,
}
}
/// A folder renamed or moved on disk as part of an undo step.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FolderMove {
pub from: String,
pub to: String,
}
impl FolderMove {
fn reversed(&self) -> Self {
Self { from: self.to.clone(), to: self.from.clone() }
}
}
/// An in-progress slider drag / brush stroke: one undo step when it ends.
#[derive(Clone, Debug)]
pub struct Interaction {
pub label: String,
pub photo: PhotoId,
pub original: Arc<DevelopSettings>,
}
/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one).
static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
pub struct Session {
library_generation: u64,
/// Auto Sync: edits to the active photo also change the other selected photos (the settings
/// that changed, nothing else).
pub auto_sync: bool,
pub catalog: Catalog,
pub source: LibrarySource,
pub filter: Filter,
pub sort: Sort,
pub selection: Selection,
/// The grid order for the current source/filter/sort (cached by library revision, and by the
/// full catalog revision when the filter, sort or source reads develop state).
/// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]).
visible: Arc<[PhotoId]>,
visible_key: Option<ViewKey>,
/// Identifies the current `visible` list: a new value (unique in the process) every time it
/// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids.
visible_gen: u64,
/// Photos in the current source with no filter on (cached like `visible`).
total: Option<(ViewKey, usize)>,
pub undo: Vec<UndoEntry>,
/// Undo steps committed in this session, ever (the history drops its oldest past 1000, so
/// its length can't tell how many steps a command just made; see [`Session::commits`]).
commits: u64,
pub redo: Vec<UndoEntry>,
/// Exact filesystem repairs from discarded history; catalog inverses stay discarded.
pub(crate) pending_sidecar_repairs: Vec<rename::SidecarMove>,
pub interaction: Option<Interaction>,
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
pub(crate) skip_auto_write: bool,
/// AI denoise: the model in use, the photos that have their picture and the work in progress.
pub(crate) denoise: denoise::State,
/// Where the host keeps face models (one folder each); `None` where there is no file system (the web).
pub face_models_dir: Option<std::path::PathBuf>,
/// Face model downloads started this session (the staged files wait in `<face_models_dir>/.downloads`).
pub face_downloads: face_download::Downloads,
/// Reverse geocoding: the parsed place names and their download (`metadata.*PlaceNames`).
pub place_names: cmd::geocode::PlaceNames,
/// The user's own list of models to download (`catalog.json` in the models folder), as last read.
pub(crate) face_catalog: lightcraft_faces::catalog::Catalog,
/// The loaded recognition model and the face embeddings made with it.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) faces: faces_index::FacesState,
/// Copied develop settings (partial JSON) for Paste.
pub clipboard: Option<Value>,
/// The folder on disk the [`LibrarySource::Folder`] view browses.
pub browse: Option<Browse>,
/// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported
/// from it and from the folders inside it.
pub library_folder: Option<String>,
/// Copied metadata (`photo.copyMetadata`): photo.setMeta params.
pub meta_clipboard: Option<Value>,
/// The photo that was active before the current one (Paste Settings from Previous).
pub previous_active: Option<PhotoId>,
/// Groups last used for Copy (Lightroom remembers them).
pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,
pub presets: Vec<lightcraft_develop::Preset>,
/// Favourite profile ids (persisted with the library, like preset favourites).
pub profile_favorites: Vec<String>,
/// Recently applied profile ids, newest first (at most [`presets::RECENT_PROFILES`]).
pub profile_recent: Vec<String>,
/// Executed commands (actions / debugging / replay).
pub journal: Vec<(String, Value)>,
/// Ops applied since the last `drain_log` (for persistence).
pending_log: Vec<Op>,
pub media: media::MediaCache,
/// Current time provider (ISO 8601); injectable for tests and wasm.
pub clock: Box<dyn Fn() -> String + Send>,
depth: u32,
/// Selected mask (Masking panel), by mask id.
pub active_mask: Option<u32>,
/// AI masks (SAM 3): the model and the last photo prepared for it.
pub segmenter: segment::Segmenter,
/// Selected spot (Remove panel), by index into the active photo's spots.
pub active_spot: Option<usize>,
/// The persistent library this session writes to (`None` = in-memory only).
pub library: Option<library::Library>,
/// Identity of current library opening; refreshed only after a successful open.
pub(crate) library_identity: Arc<()>,
/// XMP sidecar preferences (persisted with the library).
pub xmp: sidecar::XmpPrefs,
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
pub last_export: Option<serde_json::Value>,
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
pub export_presets: Vec<export::ExportPreset>,
/// Metadata presets (`metadata.*`), persisted in prefs.json.
pub metadata_presets: Vec<cmd::metadata::MetadataPreset>,
/// Saved filter-bar settings (`filter.*`), persisted in prefs.json.
pub filter_presets: Vec<cmd::filters::FilterPreset>,
/// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]),
/// persisted in prefs.json.
pub curve_presets: Vec<cmd::curves::CurvePreset>,
/// Saved colour-label name sets.
pub label_sets: Vec<cmd::manage::LabelSet>,
/// Look-alike signatures by content key (Find Similar).
pub signatures: std::collections::HashMap<String, [f32; 64]>,
/// Imported `.cube` LUT profiles.
pub lut_profiles: Vec<cmd::lut_profiles::LutProfile>,
/// The target album B adds to (`None` = the Quick Collection).
pub target_album: Option<lightcraft_catalog::AlbumId>,
/// Auto import: files seen in the watched folder and their size then (a file is imported once
/// its size held between two scans).
pub auto_import_seen: std::collections::HashMap<String, u64>,
/// Keyword sets (⌥1–⌥9 apply the current set's keywords), the one in use (`None` = Recent
/// Keywords) and the recently added keywords, newest first.
pub keyword_sets: Vec<cmd::keywords::KeywordSet>,
pub keyword_set: Option<String>,
pub recent_keywords: Vec<String>,
/// Before/After: the "before" settings chosen per photo (this session; default: the photo's
/// import state). See `cmd/before.rs`.
pub before: std::collections::HashMap<PhotoId, Arc<DevelopSettings>>,
/// File probes from the last import review (`library.importPreview`), reused by the import.
pub import_probes: std::collections::HashMap<String, media::ProbeInfo>,
/// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current
/// (see [`Session::take_folder_changes`]).
pub folder_changes: Option<sync::FolderChanges>,
/// Background tasks in flight (imports, exports, preview builds…), for the activity stack and
/// `activity.list` / `activity.cancel`.
pub activity: activity::Activity,
/// The last (or running) Build Previews.
pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>,
/// Develop defaults applied on import (persisted in prefs.json).
pub import_defaults: import::ImportDefaults,
/// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json).
pub cache_mb: u32,
/// Where smart previews are kept when not in the library folder (persisted in prefs.json).
pub smart_previews_dir: Option<std::path::PathBuf>,
/// Untouched Local records of folders not browsed for this many days are forgotten when the
/// library opens (0 = never; persisted in prefs.json). See `cmd/browse.rs`.
pub forget_local_days: u32,
/// Adds exported files to Apple Photos ([`apple_photos`]): `/usr/bin/osascript`, installed by
/// [`Session::with_fs`] on macOS; `None` elsewhere and in sessions without a file system. Tests
/// put a fake here.
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos: Option<apple_photos::Runner>,
/// This session's Apple Photos imports, running or finished (`export.photosImports`).
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos_imports: apple_photos::Imports,
/// Apple Photos imports run on a worker by default (`export.addToPhotos` without `wait`,
/// `app.export` without `background`): set by the desktop app, which answers control requests
/// on its UI thread. Off (the default), they wait for Photos, so a one-shot process
/// (lightcraft-cli, a script) never ends in the middle of one.
#[cfg(not(target_arch = "wasm32"))]
pub apple_photos_background: bool,
}
impl Default for Session {
fn default() -> Self {
Session::new()
}
}
impl Session {
pub fn library_generation(&self) -> u64 {
self.library_generation
}
pub fn new() -> Session {
Session {
library_generation: LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
auto_sync: false,
catalog: Catalog::new(),
source: LibrarySource::All,
filter: Filter::default(),
sort: Sort::default(),
selection: Selection::default(),
visible: Vec::new().into(),
visible_key: None,
visible_gen: 0,
total: None,
undo: Vec::new(),
commits: 0,
redo: Vec::new(),
pending_sidecar_repairs: Vec::new(),
interaction: None,
skip_auto_write: false,
denoise: Default::default(),
face_models_dir: None,
face_downloads: Default::default(),
place_names: Default::default(),
face_catalog: Default::default(),
#[cfg(not(target_arch = "wasm32"))]
faces: Default::default(),
clipboard: None,
meta_clipboard: None,
browse: None,
library_folder: None,
previous_active: None,
copy_groups: lightcraft_develop::SettingsGroup::default_copy(),
presets: presets::builtin(),
profile_favorites: Vec::new(),
profile_recent: Vec::new(),
journal: Vec::new(),
pending_log: Vec::new(),
media: media::MediaCache::default(),
clock: Box::new(|| "2026-09-30T12:00:00".to_string()),
depth: 0,
active_mask: None,
segmenter: segment::Segmenter::default(),
active_spot: None,
library: None,
library_identity: Arc::new(()),
xmp: sidecar::XmpPrefs::default(),
last_export: None,
export_presets: Vec::new(),
metadata_presets: Vec::new(),
filter_presets: Vec::new(),
curve_presets: Vec::new(),
label_sets: Vec::new(),
signatures: Default::default(),
lut_profiles: Vec::new(),
target_album: None,
auto_import_seen: Default::default(),
keyword_sets: Vec::new(),
keyword_set: None,
recent_keywords: Vec::new(),
before: Default::default(),
import_probes: Default::default(),
folder_changes: None,
activity: Default::default(),
preview_build: None,
import_defaults: import::ImportDefaults::default(),
cache_mb: 0,
smart_previews_dir: None,
forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS,
#[cfg(not(target_arch = "wasm32"))]
apple_photos: None,
#[cfg(not(target_arch = "wasm32"))]
apple_photos_imports: Default::default(),
#[cfg(not(target_arch = "wasm32"))]
apple_photos_background: false,
}
}
/// A session with the procedurally generated demo library loaded.
pub fn with_demo() -> Session {
let mut s = Session::new();
demo::load(&mut s);
s
}
/// Run a command by id. THE entry point for every frontend.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
// a command that validates the photos a call names isn't held back by the selection
if !(spec.explicit_targets && cmd::names_photos(params)) {
(spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?;
}
let empty = Value::Object(Default::default());
let params = if params.is_null() { &empty } else { params };
self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params))
}
/// Run `f` as command `id` with what [`Session::execute`] does around every command: the
/// panic guard, auto versions, XMP sidecar auto-write and the durable save (a failed save is
/// `NotSaved`). Not journaled. The app's import task commits its batches this way.
pub fn execute_fn(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
self.run_command(id, None, f)
}
fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
let log_start = self.pending_log.len();
let was_active = self.active();
self.depth += 1;
// last-resort guard: a panic in a command is that command's error, not a crash
let r = guard::catch(&format!("`{id}`"), || f(self)).unwrap_or_else(|e| Err(EngineError::Other(e)));
self.depth -= 1;
if self.depth == 0 && was_active.is_some() && self.active() != was_active {
self.previous_active = was_active;
if let Some(left) = was_active {
self.auto_version(left);
}
}
if r.is_ok()
&& let Some(params) = journal
&& self.depth == 0
{
self.journal.push((id.to_string(), params.clone()));
if self.journal.len() > 10_000 {
self.journal.drain(..1000);
}
}
if self.depth == 0 {
let skip = std::mem::take(&mut self.skip_auto_write);
if r.is_ok() && !skip && self.xmp.auto_write && self.interaction.is_none() && self.pending_log.len() > log_start {
self.auto_write_sidecars_since(log_start);
}
}
if self.depth == 0 && self.library.is_some() {
// Make the command durable before reporting success. When the command's own records
// can't be appended, it fails with `NotSaved`: the change stays applied in memory and
// queued, and the next save (any later command, or the frame loop) retries it. Other
// persistence trouble (a failed compaction, an older queued change still unwritten by
// a command that changed nothing) doesn't fail the command; `library.info` reports it.
let produced = self.pending_log.len() > log_start;
if let Err(e @ EngineError::NotSaved(_)) = self.persist()
&& produced
&& r.is_ok()
{
return Err(e);
}
}
r
}
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
// ---------------------------------------------------------------- ops, undo
/// Apply an op as one undoable step.
pub fn commit(&mut self, label: &str, op: Op) -> Result<()> {
let fwd = op.clone();
let inv = self.apply_catalog(op)?;
self.pending_log.push(fwd);
self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None, retry_inverse: Vec::new(), retry_sidecars: Vec::new() });
self.commits = self.commits.wrapping_add(1);
if self.undo.len() > 1000 {
let mut dropped = self.undo.remove(0);
self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars);
}
for mut dropped in self.redo.drain(..) {
self.pending_sidecar_repairs.append(&mut dropped.retry_sidecars);
}
Ok(())
}
fn apply_catalog(&mut self, op: Op) -> Result<Op> {
let inv = self.catalog.apply(op.clone())?;
let mut pending = vec![&op];
while let Some(op) = pending.pop() {
match op {
Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id),
Op::AddPhoto { photo } => self.media.forget(photo.id),
Op::Batch { ops } => pending.extend(ops),
_ => {}
}
}
Ok(inv)
}
/// [`Session::commit`] for an op that goes with a folder already renamed on disk; the undo
/// step renames it back (`folder` is the undo direction: current place → old place).
pub(crate) fn commit_with_folder(&mut self, label: &str, op: Op, folder: FolderMove) -> Result<()> {
self.commit(label, op)?;
if let Some(e) = self.undo.last_mut() {
e.folder = Some(folder);
}
Ok(())
}
/// The box to cut a face's picture from: the detector's, when the scan has looked at the face (every face is then shown
/// equally close, however loosely or tightly its own region was drawn), else the region's own box.
pub fn face_view(&self, id: PhotoId, rect: lightcraft_geom::Rect) -> lightcraft_geom::Rect {
#[cfg(not(target_arch = "wasm32"))]
if let Some(v) = self.faces.index.view(id.0, &rect) {
return v;
}
let _ = id;
rect
}
/// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background
/// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the
/// redo stack alone.
pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> {
let fwd = op.clone();
self.catalog.apply(op)?;
self.pending_log.push(fwd);
Ok(())
}
/// Leaving photo `id` after editing it: keep its settings as an automatic version (when they
/// differ from its latest version; at most [`AUTO_VERSIONS`] auto versions, oldest dropped).
/// Saved with the library but not an undo step.
pub fn auto_version(&mut self, id: PhotoId) {
let Some(p) = self.catalog.photo(id) else { return };
if !p.is_edited() || p.versions.last().is_some_and(|v| *v.settings == *p.develop) || self.interaction.is_some() {
return;
}
let mut versions = p.versions.clone();
let created = (self.clock)();
let name = lightcraft_catalog::dates::display_time(&created);
versions.push(lightcraft_catalog::Version { name, created, settings: p.develop.clone(), auto: true });
let autos = versions.iter().filter(|v| v.auto).count();
if autos > AUTO_VERSIONS
&& let Some(i) = versions.iter().position(|v| v.auto)
{
versions.remove(i);
}
let op = Op::SetVersions { id, versions };
if self.catalog.apply(op.clone()).is_ok() {
self.pending_log.push(op);
}
}
/// Fold the last `n` undo steps into one (commands that commit step by step because each op
/// depends on the state the previous one left).
/// How many undo steps were committed in this session so far: what a command that commits
/// several steps compares before and after to merge them ([`Session::merge_undo`]).
pub fn commits(&self) -> u64 {
self.commits
}
pub fn merge_undo(&mut self, n: usize, label: &str) {
if n < 2 || n > self.undo.len() {
return;
}
let Some(entries) = self.undo.get(self.undo.len() - n..) else { return };
// Folder moves and partially applied file steps stay on their own.
if entries.iter().any(|e| e.folder.is_some() || !e.retry_inverse.is_empty() || !e.retry_sidecars.is_empty()) {
return;
}
let tail = self.undo.split_off(self.undo.len() - n);
let ops = tail.into_iter().rev().map(|e| e.op).collect();
self.undo.push(UndoEntry {
label: label.to_string(),
op: Op::Batch { ops },
folder: None,
retry_inverse: Vec::new(),
retry_sidecars: Vec::new(),
});
}
/// Apply without recording undo (interactive previews).
fn apply_silent(&mut self, op: Op) -> Result<()> {
self.catalog.apply(op)?;
Ok(())
}
pub fn undo_step(&mut self) -> Result<String> {
self.undo_step_with(&rename::RealFs)
}
pub(crate) fn undo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result<String> {
let mut e = self.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let redo = match self.apply_with_files(&mut e, fs) {
Ok(r) => r,
Err(err) => {
self.undo.push(e);
return Err(err);
}
};
self.show_undone(&e.op);
self.pending_log.push(e.op);
self.redo.push(UndoEntry {
label: e.label.clone(),
op: redo,
folder: e.folder.as_ref().map(FolderMove::reversed),
retry_inverse: Vec::new(),
retry_sidecars: Vec::new(),
});
Ok(e.label)
}
pub fn redo_step(&mut self) -> Result<String> {
self.redo_step_with(&rename::RealFs)
}
pub(crate) fn redo_step_with(&mut self, fs: &dyn rename::MoveFs) -> Result<String> {
let mut e = self.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let undo = match self.apply_with_files(&mut e, fs) {
Ok(r) => r,
Err(err) => {
self.redo.push(e);
return Err(err);
}
};
self.show_undone(&e.op);
self.pending_log.push(e.op);
self.undo.push(UndoEntry {
label: e.label.clone(),
op: undo,
folder: e.folder.as_ref().map(FolderMove::reversed),
retry_inverse: Vec::new(),
retry_sidecars: Vec::new(),
});
Ok(e.label)
}
/// After an undo or redo that changed one photo (its edit, rating, flag, label, metadata or
/// versions), that photo becomes the active one, as in Lightroom Classic, so the change is on
/// screen (issue #293). It joins the selection if it isn't in it; nothing changes for steps
/// that touch several photos, albums or files, or for a photo not in the current view.
fn show_undone(&mut self, op: &Op) {
let Some(id) = single_photo(op, 0) else { return };
if self.selection.active == Some(id) || !self.visible().contains(&id) {
return;
}
if !self.selection.contains(id) {
self.selection = Selection { ids: vec![id], active: Some(id) };
} else {
self.selection.active = Some(id);
}
self.active_mask = None;
self.active_spot = None;
}
/// Apply an undo/redo op, first renaming the step's folder and moving the files its renames
/// imply (all or nothing). Files that can't be moved back after a failure are reported and the
/// library follows them (a logged change outside the undo history), so none goes missing; the
/// folder is only moved back when no file was left behind at its new path.
fn apply_with_files(&mut self, entry: &mut UndoEntry, fs: &dyn rename::MoveFs) -> Result<Op> {
// The entry is detached from self.undo/self.redo: include it before any global
// repair can retire, then retry revalidates it before moving its own packets.
rename::validate_sidecars(fs, &entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
// Repair discarded file steps before another path change can stale their exact paths.
// Unrelated metadata history must remain usable while a sidecar is still unwritable.
if entry.folder.is_some() || !entry.retry_sidecars.is_empty() || !self.file_moves(&entry.op).is_empty() {
self.repair_sidecars_with(fs).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
}
rename::retry_sidecars(fs, &mut entry.retry_sidecars).map_err(|e| EngineError::Other(format!("can't move the files back: {e}")))?;
let op = &entry.op;
let folder = entry.folder.as_ref();
if let Some(f) = folder {
cmd::browse::folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?;
}
let undo_folder = || {
if let Some(f) = folder {
let _ = cmd::browse::folder_on_disk(&f.to, &f.from);
}
};
let moves = self.file_moves(op);
if let Err(mut e) = rename::move_all(fs, &moves) {
entry.retry_sidecars.append(&mut e.pending_sidecars);
if e.stuck.is_empty() {
undo_folder();
}
let (message, mut inverses) = self.follow_stuck(op, e, false);
// A later successful retry observes the corrected paths. Retain their old paths
// now, so the opposite history direction still restores the entire original step.
inverses.append(&mut entry.retry_inverse);
entry.retry_inverse = inverses;
return Err(EngineError::Other(format!("can't move the files back: {message}")));
}
match self.apply_catalog(op.clone()) {
Ok(inv) => {
if let Some(f) = folder {
cmd::browse::follow_folder(self, &f.from, &f.to);
}
if entry.retry_inverse.is_empty() {
Ok(inv)
} else {
let mut ops = match inv {
Op::Batch { ops } => ops,
op => vec![op],
};
ops.append(&mut entry.retry_inverse);
Ok(Op::Batch { ops })
}
}
Err(e) => {
let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect();
if let Err(mut be) = rename::move_all(fs, &back) {
entry.retry_sidecars.append(&mut be.pending_sidecars);
return Err(EngineError::Other(format!("{e}; the files could not all be moved back: {}", be.message)));
}
undo_folder();
Err(e)
}
}
}
/// Ops applied since the last call, for the op-log store.
pub fn drain_log(&mut self) -> Vec<Op> {
std::mem::take(&mut self.pending_log)
}
// ---------------------------------------------------------------- develop edits
/// The photo being edited (the active photo).
pub fn active(&self) -> Option<PhotoId> {
self.selection.active.filter(|id| self.catalog.photo(*id).is_some())
}
pub fn develop_of(&self, id: PhotoId) -> Option<Arc<DevelopSettings>> {
self.catalog.photo(id).map(|p| p.develop.clone())
}
/// Change a photo's develop settings. During an interaction the change is previewed without an
/// undo step; otherwise it is committed with a history entry.
pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> {
let now = (self.clock)();
let settings = Arc::new(settings);
if let Some(i) = &self.interaction
&& i.photo == id
{
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
}
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
ops.extend(self.auto_sync_ops(id, &settings, label));
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
self.commit(label, op)
}
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
/// other selected photos: only the settings that changed; never spot removal or red eye (they
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
/// history / snapshot restores.
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
if !self.auto_sync
|| self.active() != Some(id)
|| self.selection.ids.len() < 2
|| label.starts_with("History:")
|| label.starts_with("Restore ")
{
return Vec::new();
}
let Some(old) = self.develop_of(id) else { return Vec::new() };
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
if let Some(o) = delta.as_object_mut() {
for k in ["spots", "red_eye", "version", "process"] {
o.remove(k);
}
if o.is_empty() {
return Vec::new();
}
}
self.selection
.ids
.iter()
.filter(|x| **x != id)
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
.filter_map(|(x, d)| {
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
})
.collect()
}
/// The op that sets a photo's develop settings and appends a History entry (for batches).
pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {
self.catalog.photo(id)?;
let settings = Arc::new(settings);
let step = lightcraft_catalog::HistoryStep { label: label.into(), settings: settings.clone() };
Some(Op::Batch {
ops: vec![Op::SetDevelop { id, settings, label: label.into(), edited: Some((self.clock)()) }, Op::PushHistory { id, step }],
})
}
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
if self.interaction.is_some() {
self.end_interaction()?;
}
let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?;
let original = self.develop_of(id).unwrap_or_default();
self.interaction = Some(Interaction { label: label.into(), photo: id, original });
Ok(())
}
/// Commit the interaction as one undo step (no-op if nothing changed).
pub fn end_interaction(&mut self) -> Result<()> {
let Some(i) = self.interaction.take() else { return Ok(()) };
let Some(cur) = self.develop_of(i.photo) else { return Ok(()) };
if *cur == *i.original {
return Ok(());
}
// Restore the original silently, then commit the final value as one step.
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?;
self.set_develop(i.photo, (*cur).clone(), &i.label)
}
pub fn cancel_interaction(&mut self) -> Result<()> {
if let Some(i) = self.interaction.take() {
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original, label: i.label, edited: None })?;
}
Ok(())
}
// ---------------------------------------------------------------- library view
/// Photos shown in the grid/filmstrip for the current source, filter and sort.
pub fn visible(&mut self) -> &[PhotoId] {
// a develop edit (a slider tick) only redoes the query when the filter, sort or source reads develop state
let reads_develop = self.source_reads_develop() || self.sort.reads_develop() || self.catalog.filter_reads_develop(&self.filter);
let revision = self.catalog.view_revision(reads_develop);
// the Missing Photos view fills in as the background checks find files gone
let availability =
(self.source == LibrarySource::Missing && self.media.availability.is_background()).then(|| self.media.availability.generation());
if !self.visible_key.as_ref().is_some_and(|k| k.is(self, revision, true, availability)) {
let key = ViewKey::of(self, revision, true, availability);
// "in the last N days" rules count back from the session's clock
lightcraft_catalog::rules::set_now(Some((self.clock)()));
let mut f = self.source.to_filter(&self.filter, &self.catalog);
if self.source == LibrarySource::Folder {
// no folder chosen: nothing (an empty path matches nothing)
let b = self.browse.clone().unwrap_or_default();
f.folder = Some(b.path);
f.subfolders = b.subfolders;
}
if self.source == LibrarySource::LibraryFolder {
// no folder chosen: nothing (`.` names no folder)
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
}
let mut visible = self.catalog.query(&f, &self.sort);
if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() {
// the shown folder lost its last photo (deleted, moved, removed): everything, not
// an empty grid under the name of a folder that is gone from the sidebar
self.source = LibrarySource::All;
self.library_folder = None;
return self.visible();
}
if matches!(self.source, LibrarySource::Album(_))
&& self.sort.key == lightcraft_catalog::SortKey::CaptureDate
&& let LibrarySource::Album(a) = self.source
&& let Some(al) = self.catalog.album(a)
&& !al.is_smart()
&& self.filter == Filter::default()
{
let order = al.photos.clone();
visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX));
if !self.sort.ascending {
visible.reverse();
}
}
if self.source == LibrarySource::RecentlyAdded {
// newest import first, whatever the sort (the grid groups by import day)
let cat = &self.catalog;
visible.sort_by(|a, b| {
let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default();
key(b).cmp(&key(a)).then(a.cmp(b))
});
}
if self.source == LibrarySource::Missing {
// only the photos the query kept (library photos, not Local browse records) are checked
let (cat, avail) = (&self.catalog, &self.media.availability);
visible.retain(|id| cmd::missing::is_missing(cat, avail, *id));
}
if self.source != LibrarySource::RecentlyDeleted {
visible = self.catalog.arrange_stacks(&visible);
}
self.visible = visible.into();
self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.visible_key = Some(key);
}
&self.visible
}
/// Whether the library still holds a photo imported from the folder a `LibraryFolder` source
/// shows.
fn folder_holds_photos(&self) -> bool {
let f = Filter { library_folder: self.library_folder.clone(), ..Default::default() };
self.library_folder.is_some() && !self.catalog.query(&f, &Sort::default()).is_empty()
}
/// Photos in the current source (folder, album, …) before the filter bar and search narrow
/// them; `None` where that is not a separate number (Missing Photos).
pub fn source_total(&mut self) -> Option<usize> {
if self.source == LibrarySource::Missing {
return None;
}
let revision = self.catalog.view_revision(self.source_reads_develop());
if !self.total.as_ref().is_some_and(|t| t.0.is(self, revision, false, None)) {
let key = ViewKey::of(self, revision, false, None);
let mut f = self.source.to_filter(&Filter::default(), &self.catalog);
if self.source == LibrarySource::Folder {
let b = self.browse.clone().unwrap_or_default();
f.folder = Some(b.path);
f.subfolders = b.subfolders;
}
if self.source == LibrarySource::LibraryFolder {
f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into()));
}
let n = self.catalog.query(&f, &self.sort).len();
self.total = Some((key, n));
}
self.total.as_ref().map(|t| t.1)
}
/// Whether what the current source holds can change with a develop edit (a smart album
/// whose rules read develop state; see [`lightcraft_catalog::revision`]).
fn source_reads_develop(&self) -> bool {
match self.source {
LibrarySource::Album(a) => self.catalog.album_reads_develop(a),
_ => false,
}
}
pub fn visible_cloned(&mut self) -> Vec<PhotoId> {
self.visible().to_vec()
}
/// The visible photos without copying them, with their generation: equal generations mean
/// the very same list (same library, source, filter, sort), so a frontend can cache
/// whatever it derives from the list under that number.
pub fn visible_shared(&mut self) -> (u64, Arc<[PhotoId]>) {
self.visible();
(self.visible_gen, self.visible.clone())
}
/// Targets of photo commands: explicit `ids`/`id` param, else the selection.
pub fn targets(&self, p: &Value) -> Vec<PhotoId> {
if let Some(a) = p.get("ids").and_then(Value::as_array) {
return a.iter().filter_map(Value::as_u64).map(PhotoId).collect();
}
if let Some(id) = p.get("id").and_then(Value::as_u64) {
return vec![PhotoId(id)];
}
if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() }
}
}
/// What [`Session::visible`] (or [`Session::source_total`]) was worked out from. Compared every
/// frame, so without formatting or copying anything.
#[derive(Clone, Debug, PartialEq)]
struct ViewKey {
revision: lightcraft_catalog::ViewRevision,
source: LibrarySource,
/// The filter bar and sort (`None` for the source total, which ignores them).
view: Option<(Filter, Sort)>,
browse: Option<Browse>,
library_folder: Option<String>,
availability: Option<u64>,
}
impl ViewKey {
fn of(s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option<u64>) -> ViewKey {
ViewKey {
revision,
source: s.source,
view: filtered.then(|| (s.filter.clone(), s.sort)),
browse: s.browse.clone(),
library_folder: s.library_folder.clone(),
availability,
}
}
fn is(&self, s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option<u64>) -> bool {
let view = match &self.view {
Some((f, sort)) => filtered && *f == s.filter && *sort == s.sort,
None => !filtered,
};
self.revision == revision
&& self.source == s.source
&& view
&& self.browse == s.browse
&& self.library_folder == s.library_folder
&& self.availability == availability
}
}
/// Automatic versions kept per photo.
pub const AUTO_VERSIONS: usize = 20;
/// The parts of `new` that differ from `old` (objects recurse; anything else is taken whole).
pub fn json_delta(old: &Value, new: &Value) -> Option<Value> {
match (old, new) {
(Value::Object(a), Value::Object(b)) => {
let m: serde_json::Map<String, Value> =
b.iter().filter_map(|(k, nv)| json_delta(a.get(k).unwrap_or(&Value::Null), nv).map(|d| (k.clone(), d))).collect();
(!m.is_empty()).then_some(Value::Object(m))
}
(a, b) if a == b => None,
(_, b) => Some(b.clone()),
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_album_order;
#[cfg(test)]
mod tests_albums_local;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_crop_angle;
#[cfg(test)]
mod tests_denoise;
#[cfg(test)]
mod tests_export;
#[cfg(test)]
mod tests_face_models;
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests_face_recognize;
#[cfg(test)]
mod tests_filesystem_history;
#[cfg(test)]
mod tests_folders;
#[cfg(test)]
mod tests_forget_local;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_import_move;
#[cfg(test)]
mod tests_invalidation;
#[cfg(test)]
mod tests_keyword_list;
#[cfg(test)]
mod tests_libops;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_lightroom_folder;
#[cfg(test)]
mod tests_merge;
#[cfg(test)]
mod tests_organize;
#[cfg(test)]
mod tests_persist;
#[cfg(test)]
mod tests_prefs;
#[cfg(test)]
mod tests_process;
#[cfg(test)]
mod tests_segment;
#[cfg(test)]
mod tests_settings_files;
#[cfg(test)]
mod tests_spots;
#[cfg(test)]
mod tests_sync;
#[cfg(test)]
mod tests_xmp;