LRC-LIB-PREVIEWS: build standard-sized / 1:1 previews in the background
library.buildPreviews {size: standard (Settings → Performance preview size) | full, ids?,
wait?} renders each photo's grid thumbnail and loupe view into the memory + disk preview cache
(selected photos, else all in view) on a background thread; library.previewProgress /
cancelPreviews. File ▸ Previews: Build Standard-Sized / 1:1, Stop, Discard Preview Cache;
toasts when a build starts and finishes. Also silences the wasm-only unused `threads` warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b8b2379215
commit
6a0d5346e8
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<usize>) -> Vec<RenderJob> {
|
||||
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<Vec<RenderJob>>, 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<Value> {
|
||||
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<Vec<RenderJob>> = 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<CommandSpec> {
|
||||
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()))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -135,6 +135,8 @@ pub struct Session {
|
|||
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 (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).
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u64> = 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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ pub struct JobPool<S, R> {
|
|||
shared: Arc<Shared<S, R>>,
|
||||
tx: Sender<Done<S, R>>,
|
||||
rx: Receiver<Done<S, R>>,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
threads: usize,
|
||||
started: bool,
|
||||
seq: u64,
|
||||
|
|
|
|||
|
|
@ -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<String>) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<Vec<MenuNode>> {
|
|||
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) };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue