diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 50ccb4bd6..08dae2644 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -54,13 +54,14 @@ impl PreviewBuild { /// 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 { +pub(crate) 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)); + // (after the view: it renders from the source the view loaded, see `run_photo`) + v.extend(s.thumb_job(id, THUMB_SIZES[THUMB_SIZES.len() - 1])); v } @@ -76,17 +77,66 @@ fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard { task } -fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard) { - for photo_jobs in jobs { - if state.cancel.load(Ordering::Relaxed) { - break; +/// Render one photo's jobs in order. The source the first job decodes serves the ones after it +/// (the grid thumbnail is made from the view's source instead of decoding the file again). +pub(crate) fn run_photo(jobs: Vec) -> bool { + let mut decoded: Option = None; + let mut ok = true; + for mut j in jobs { + if let Some(src) = &decoded + && !matches!(j.source, crate::media::SourceRef::Loaded(_)) + { + j.source = crate::media::SourceRef::Loaded(Box::new(src.clone())); } - let ok = photo_jobs.into_iter().all(|j| j.run().rendered.is_ok()); - if !ok { - state.failed.fetch_add(1, Ordering::Relaxed); + let r = j.run(); + ok &= r.rendered.is_ok(); + if decoded.is_none() { + decoded = r.loaded; } - let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1); - task.progress(done as u64, state.total as u64); + } + ok +} + +/// How many photos render side by side: as many as an export batch for standard previews (each +/// render spreads over the cores already; the others fill them while one decodes), two for 1:1 +/// previews (a full-size source each). +fn lanes(n: usize, full: bool) -> usize { + let l = crate::export::export_parallelism(n); + if full { l.min(2) } else { l } +} + +fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) { + let total = jobs.len(); + let queue = std::sync::Mutex::new(jobs.into_iter()); + let work = || { + loop { + if state.cancel.load(Ordering::Relaxed) { + break; + } + let next = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).next(); + let Some(photo_jobs) = next else { break }; + let ok = crate::guard::catch("building previews", || run_photo(photo_jobs)).unwrap_or(false); + if !ok { + state.failed.fetch_add(1, Ordering::Relaxed); + } + let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1); + task.progress(done as u64, total as u64); + } + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::scope(|sc| { + for k in 1..lanes.max(1) { + // a lane that can't start leaves its photos to the others (and to this thread) + if std::thread::Builder::new().name(format!("previews-{k}")).spawn_scoped(sc, work).is_err() { + break; + } + } + work(); + }); + #[cfg(target_arch = "wasm32")] + { + let _ = lanes; + work(); } state.finished.store(true, Ordering::Relaxed); } @@ -107,9 +157,10 @@ fn build(s: &mut Session, p: &Value) -> Result { let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); let task = task_for(s, &state); + let lanes = lanes(jobs.len(), edge.is_none()); let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32"); if wait { - run_all(jobs, &state, &task); + run_all(jobs, &state, &task, lanes); return Ok(state.json()); } #[cfg(not(target_arch = "wasm32"))] @@ -118,7 +169,7 @@ fn build(s: &mut Session, p: &Value) -> Result { let st = state.clone(); std::thread::Builder::new() .name("lc-build-previews".into()) - .spawn(move || run_all(jobs, &st, &task)) + .spawn(move || run_all(jobs, &st, &task, lanes)) .map_err(|e| bad(C, format!("could not start: {e}")))?; } Ok(state.json()) @@ -465,3 +516,37 @@ pub fn specs() -> Vec { }), ] } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{jobs_for, run_photo}; + use crate::Session; + use crate::media::SourceRef; + + // Given a photo's preview jobs, the grid thumbnail is made from the source the view decoded, not + // by reading the file again (here: a thumbnail job pointing at a file that is not there) + #[test] + fn preview_build_makes_the_thumbnail_from_the_views_source() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let mut jobs = jobs_for(&mut s, id, Some(512)); + assert_eq!(jobs.len(), 2, "view and thumbnail"); + assert!(jobs[0].view_cache.is_some(), "the view renders first"); + jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None }; + assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source"); + } + + // Given many photos, a standard preview build renders them side by side and counts every one + #[test] + fn preview_build_of_many_photos_counts_each_once() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().map(|p| p.id.0).collect(); + assert!(ids.len() > 4); + let r = s.execute("library.buildPreviews", &json!({"size": "standard", "edge": 512, "ids": ids, "wait": true})).unwrap(); + assert_eq!(r["done"], json!(ids.len()), "{r}"); + assert_eq!(r["total"], json!(ids.len())); + assert_eq!(r["failed"], json!(0)); + } +}