diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0f68c7ab..802d81fa 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,6 +21,7 @@ pub mod missing; mod organize; mod prefs; mod preset_files; +pub mod previews; mod query; mod xmp; @@ -118,6 +119,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(organize::specs()); v.extend(keywords::specs()); v.extend(manage::specs()); + v.extend(previews::specs()); v.extend(merge::specs()); v.extend(query::specs()); v.extend(xmp::specs()); diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs new file mode 100644 index 00000000..918949b1 --- /dev/null +++ b/crates/engine/src/cmd/previews.rs @@ -0,0 +1,118 @@ +//! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size, +//! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant. +//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline +//! with `wait` (CLI, MCP, tests). + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::media::{RenderJob, THUMB_SIZES}; +use crate::{Result, Session}; + +/// The long edge of a standard-sized preview. +pub const STANDARD_EDGE: usize = 2048; + +/// A preview build in progress. +#[derive(Debug, Default)] +pub struct PreviewBuild { + pub total: usize, + pub done: AtomicUsize, + pub failed: AtomicUsize, + pub cancel: AtomicBool, + pub finished: AtomicBool, +} + +impl PreviewBuild { + pub fn json(&self) -> Value { + json!({ + "total": self.total, + "done": self.done.load(Ordering::Relaxed), + "failed": self.failed.load(Ordering::Relaxed), + "running": !self.finished.load(Ordering::Relaxed), + "cancelled": self.cancel.load(Ordering::Relaxed), + }) + } +} + +/// The jobs that build `id`'s previews: the largest grid thumbnail, then the view render at +/// `edge` (`None` = 1:1, the photo's full size). +fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option) -> Vec { + let Some(p) = s.catalog.photo(id) else { return Vec::new() }; + let full = p.width.max(p.height) as usize; + let e = edge.unwrap_or(full).min(full.max(1)).max(1); + let mut v = Vec::new(); + v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])); + v.extend(s.loupe_job(id, e, e, true)); + v +} + +fn run_all(jobs: Vec>, state: &PreviewBuild) { + for photo_jobs in jobs { + if state.cancel.load(Ordering::Relaxed) { + break; + } + let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok()); + if !ok { + state.failed.fetch_add(1, Ordering::Relaxed); + } + state.done.fetch_add(1, Ordering::Relaxed); + } + state.finished.store(true, Ordering::Relaxed); +} + +fn build(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.buildPreviews"; + let edge = match str_param(p, "size").unwrap_or("standard") { + "standard" => Some(p.get("edge").and_then(Value::as_u64).map_or(STANDARD_EDGE, |e| e.clamp(256, 8192) as usize)), + "full" | "1:1" => None, + other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))), + }; + if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) { + return Err(bad(C, "a preview build is already running")); + } + // explicit ids, else the selection, else everything in view + let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let jobs: Vec> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); + let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); + s.preview_build = Some(state.clone()); + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32"); + if wait { + run_all(jobs, &state); + return Ok(state.json()); + } + #[cfg(not(target_arch = "wasm32"))] + { + let st = state.clone(); + std::thread::Builder::new() + .name("lc-build-previews".into()) + .spawn(move || run_all(jobs, &st)) + .map_err(|e| bad(C, format!("could not start: {e}")))?; + } + Ok(state.json()) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "library.buildPreviews", + "Build Previews", + [], + None, + "{size?: standard (2048 px, or `edge`) | full (1:1), ids?, wait?: bool} — render the grid thumbnail and loupe view of the selected photos (else all in view) into the preview cache, in the background unless `wait` → {total, done, failed, running}", + always, + build + ), + cmd!(query "library.previewProgress", "Preview Build Progress", [], None, "{} → {total, done, failed, running, cancelled} | null", always, |s, _| { + Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json())) + }), + cmd!("library.cancelPreviews", "Cancel Preview Build", [], None, "{}", always, |s, _| { + if let Some(b) = &s.preview_build { + b.cancel.store(true, Ordering::Relaxed); + } + Ok(s.preview_build.as_ref().map_or(Value::Null, |b| b.json())) + }), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9997f7aa..75d2444b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -135,6 +135,8 @@ pub struct Session { pub before: std::collections::HashMap>, /// File probes from the last import review (`library.importPreview`), reused by the import. pub import_probes: std::collections::HashMap, + /// The last (or running) Build Previews. + pub preview_build: Option>, /// 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). @@ -188,6 +190,7 @@ impl Session { recent_keywords: Vec::new(), before: Default::default(), import_probes: Default::default(), + preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8548d167..b1fff44c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -481,3 +481,41 @@ fn json_delta_keeps_only_changes() { assert_eq!(crate::json_delta(&a, &b), Some(json!({"light": {"exposure": 1}, "masks": [1, 2]}))); assert_eq!(crate::json_delta(&a, &a), None); } + +#[test] +fn build_previews_fills_the_cache() { + use lightcraft_catalog::PhotoId; + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + let cached = |s: &mut Session, id: u64| { + let t = s.thumb_job(PhotoId(id), 512).unwrap(); + let (c, k) = t.cache.clone().unwrap(); + let q = s.quick_view_job(PhotoId(id), 1024, true).unwrap(); + let (vc, vk) = q.cached[0].clone(); + (c.get(k).is_some(), vc.get(vk).is_some()) + }; + assert_eq!(cached(&mut s, ids[0]), (false, false)); + let r = s.execute("library.buildPreviews", &json!({"ids": [ids[0], ids[1]], "size": "standard", "edge": 512, "wait": true})).unwrap(); + assert_eq!((r["total"].as_u64(), r["done"].as_u64(), r["failed"].as_u64(), r["running"].as_bool()), (Some(2), Some(2), Some(0), Some(false))); + // view previews are written to the cache in the background + let t0 = std::time::Instant::now(); + while cached(&mut s, ids[1]) != (true, true) && t0.elapsed().as_secs() < 20 { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(cached(&mut s, ids[0]), (true, true), "thumbnail and loupe view are ready"); + assert_eq!(cached(&mut s, ids[2]), (false, false), "only the photos asked for"); + // background build: returns at once, progress by command + let r = s.execute("library.buildPreviews", &json!({"ids": [ids[2]], "edge": 512})).unwrap(); + assert_eq!(r["total"], 1); + let t0 = std::time::Instant::now(); + loop { + let p = s.execute("library.previewProgress", &json!({})).unwrap(); + if p["running"] == false { + assert_eq!(p["done"], 1); + break; + } + assert!(t0.elapsed().as_secs() < 60, "build finishes"); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(s.execute("library.buildPreviews", &json!({"size": "huge"})).is_err()); +} diff --git a/crates/preview/src/pool.rs b/crates/preview/src/pool.rs index be1bbc64..a0ea185f 100644 --- a/crates/preview/src/pool.rs +++ b/crates/preview/src/pool.rs @@ -43,6 +43,7 @@ pub struct JobPool { shared: Arc>, tx: Sender>, rx: Receiver>, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] threads: usize, started: bool, seq: u64, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 31fad7da..f71e4f86 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -193,6 +193,30 @@ impl LightcraftApp { } /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). + /// Announce the start and end of a Build Previews run. + fn preview_build_status(&mut self, ctx: &egui::Context) { + use std::sync::atomic::Ordering; + let Some(b) = self.session.preview_build.clone() else { return }; + let key = std::sync::Arc::as_ptr(&b) as usize; + if b.finished.load(Ordering::Relaxed) { + if self.ui.preview_build_seen != Some((key, true)) { + self.ui.preview_build_seen = Some((key, true)); + let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed)); + let mut msg = format!("Previews ready for {done} photo{}", if done == 1 { "" } else { "s" }); + if failed > 0 { + msg.push_str(&format!(" · {failed} couldn't be rendered")); + } + self.toast(ctx, msg); + } + } else { + if self.ui.preview_build_seen != Some((key, false)) { + self.ui.preview_build_seen = Some((key, false)); + self.toast(ctx, format!("Building previews for {} photos…", b.total)); + } + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + } + pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { let t = ctx.input(|i| i.time); self.ui.toast = Some((text.into(), t + 1.4)); @@ -349,6 +373,7 @@ impl LightcraftApp { self.renderer.poll(ctx, &mut self.session); merge::poll(self, ctx); import::tick(self, ctx); + self.preview_build_status(ctx); self.session.persist_if_dirty(); self.collect_screenshots(ctx); self.issue_screenshots(ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1485e616..faee428f 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -71,6 +71,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Export with Preset", "---", "library.toggleAutoWriteXmp", + "@Previews", "---", "app.quit", ], @@ -278,6 +279,24 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); let has = active.is_some(); Some(match name { + "Previews" => { + let running = app.session.preview_build.as_ref().is_some_and(|b| !b.finished.load(std::sync::atomic::Ordering::Relaxed)); + let scope = if app.session.selection.ids.len() > 1 { "Selected" } else { "Visible" }; + vec![ + item( + "library.buildPreviews", + json!({"size": "standard", "edge": app.ui.settings.preview_edge}), + format!("Build Standard-Sized Previews ({scope})"), + None, + !running, + None, + ), + item("library.buildPreviews", json!({"size": "full"}), format!("Build 1:1 Previews ({scope})"), None, !running, None), + item("library.cancelPreviews", Value::Null, "Stop Building Previews", None, running, None), + MenuNode::Separator, + item("library.clearPreviews", Value::Null, "Discard Preview Cache", None, true, None), + ] + } "Set Rating" => (0..=5u8) .map(|r| { let label = if r == 0 { "None".to_string() } else { "★".repeat(r as usize) }; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 93f9dd4e..d4c2747b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -170,6 +170,9 @@ pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120]; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + /// The Build Previews run last announced (its identity, finished?). + #[serde(skip)] + pub preview_build_seen: Option<(usize, bool)>, pub view: ViewMode, pub left_panel: bool, pub right: RightPanel, @@ -397,6 +400,7 @@ pub enum Dialog { impl Default for UiState { fn default() -> Self { UiState { + preview_build_seen: None, view: ViewMode::Detail, left_panel: false, right: RightPanel::Edit, diff --git a/docs/parity.md b/docs/parity.md index c0d5b34e..d7ccbd50 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -47,10 +47,10 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | X. Cross-cutting behaviours (BEHAV) | 13 | 0 | 4 | 1 | 8/8 (100%) | 5/5 (100%) | | Y. Menus | 70 | 7 | 8 | 8 | 46/47 (98%) | 19/23 (83%) | | Z. Keyboard shortcuts (desktop) | 68 | 8 | 4 | 1 | 49/52 (94%) | 17/23 (74%) | -| Lightroom Classic extras | 17 | 13 | 49 | 9 | — | 16/21 (76%) | -| **Total** | 332 | 44 | 124 | 37 | 193/198 (97%) | 119/144 (83%) | +| Lightroom Classic extras | 18 | 12 | 49 | 9 | — | 17/21 (81%) | +| **Total** | 333 | 43 | 124 | 37 | 193/198 (97%) | 120/144 (83%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.8%** of 500 in-scope rows — P0 98.7% of 198 · P1 89.9% of 144 · P2 18.4% of 158. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **70.9%** of 500 in-scope rows — P0 98.7% of 198 · P1 90.3% of 144 · P2 18.4% of 158. ## Top gaps @@ -709,7 +709,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ⬜ | | | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate` | File → Find Missing Photos… (same name and size anywhere in a folder, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. No missing-photos collection | | LRC-LIB-CONVERT | Convert to DNG | P2 | ⬜ | `crates/raw/src/dngwrite.rs` | writer exists, not exposed | -| LRC-LIB-PREVIEWS | Build / discard previews | P1 | 🟡 | `cmd:library.clearPreviews`, `crates/preview/src/lib.rs` | disk thumbnail cache; no build-1:1 / smart previews | +| LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (progress toasts, stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ⬜ | | | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | | LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history |