mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Perf: preview builds render photos side by side and make the thumbnail from the view's source
library.buildPreviews rendered one photo at a time, and each photo's thumbnail job decoded the file again at thumbnail level after (before, in fact) the view job had decoded it at preview level. Now the view renders first and its decoded source serves the thumbnail, and photos render in as many lanes as an export batch (two for 1:1 previews, a full-size source each); a panic in one photo counts as that photo's failure. 100 24 MP ARWs, standard previews, loaded machine: 49.4 s -> 38.4 s (CPU 3m28 -> 2m49). Sources: own design. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
0ba118de92
commit
07ae88e923
1 file changed
+98
-13
@@ -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<usize>) -> Vec<RenderJob> {
|
||||
pub(crate) 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));
|
||||
// (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<Vec<RenderJob>>, 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<RenderJob>) -> bool {
|
||||
let mut decoded: Option<crate::media::DecodedSource> = 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<Vec<RenderJob>>, 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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
#[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<u64> = 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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user