diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 77fb2fc2d..617aa071c 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -587,6 +587,12 @@ fn snapshot(args: &[String]) -> Result<(), String> { eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3); } let services = lightcraft_ui_egui::Services { + write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| { + if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(p, b).map_err(|e| format!("{p}: {e}")) + })), write: Some(Box::new(|p: &str, b: &[u8]| { if let Some(dir) = Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; @@ -672,6 +678,15 @@ fn snapshot(args: &[String]) -> Result<(), String> { } eprintln!("lightcraft-cli snapshot: wrote {path} ({}×{})", r["result"]["width"], r["result"]["height"]); } + // a background export started by the script finishes before we exit (its files would be cut off) + if h.app.export.is_some() { + eprintln!("lightcraft-cli snapshot: waiting for the background export"); + let te = std::time::Instant::now(); + while h.app.export.is_some() && te.elapsed() < std::time::Duration::from_secs(3600) { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames()); Ok(()) } diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index d99998402..54cd703e8 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -155,6 +155,8 @@ fn services(originals: Originals, backend: Option, ctx: egui::Context) pick_preset_files: None, save_preset_file: None, write: Some(Box::new(download)), + // downloads happen on the main thread: exports run in the foreground on the web + write_shared: None, png: Some(Box::new(|img: &lightcraft_raster::Rgba8| { lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 2ba700e09..dc8e7e0cb 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -141,6 +141,12 @@ fn services() -> Services { .save_file() .map(|p| p.to_string_lossy().to_string()) })), + write_shared: Some(std::sync::Arc::new(|p: &str, b: &[u8]| { + if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + std::fs::write(p, b).map_err(|e| e.to_string()) + })), write: Some(Box::new(|p: &str, b: &[u8]| { if let Some(dir) = std::path::Path::new(p).parent().filter(|d| !d.as_os_str().is_empty()) { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index fadb64cd2..c673ee161 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -820,57 +820,108 @@ pub fn output_size(p: &lightcraft_catalog::Photo, o: &ExportOptions) -> (usize, /// Render photo `id` at the requested size and encode it (or copy / convert its original for /// [`ExportFormat::Original`] / [`ExportFormat::Dng`]). pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result { - let p = session.catalog.photo(id).ok_or("no such photo")?; - let file_name = o.file_name_for_dated(&p.file_name, seq, p.captured.as_deref()); - if !o.format.is_rendered() { - return export_file(session, id, o, file_name); - } - let (w, h) = output_size(p, o); - let meta = export_metadata(p, o); - let r = session.render_export(id, w, h, o.effective_space(), o.effective_depth())?; - let bytes = encode_rendered(&r, o, meta.as_ref())?; - Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) + prepare_export(session, id, o, seq)?.run() } -/// [`ExportFormat::Original`] (the file's bytes + an XMP sidecar) or [`ExportFormat::Dng`] (the raw -/// data re-encoded as a lossless DNG with the edits in its XMP). -fn export_file(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, file_name: String) -> Result { +/// One photo's export, set up from the session ([`prepare_export`]). [`PreparedExport::run`] does +/// the heavy part (read, decode, render, encode) without the session, so it can run on another +/// thread. +pub struct PreparedExport { + pub photo: lightcraft_catalog::PhotoId, + pub file_name: String, + work: Work, +} + +struct RenderWork { + job: crate::media::RenderJob, + meta: Option, + opts: ExportOptions, +} + +enum Work { + Render(Box), + /// [`ExportFormat::Original`] (the file's bytes + an XMP sidecar) or [`ExportFormat::Dng`] (the + /// raw data re-encoded as a lossless DNG with the edits in its XMP). + File { + path: String, + read: Option, + packet: String, + dng: bool, + label: String, + size: (usize, usize), + }, +} + +/// Set up the export of photo `id` at 1-based position `seq` of a batch. +pub fn prepare_export( + session: &mut crate::Session, + id: lightcraft_catalog::PhotoId, + o: &ExportOptions, + seq: usize, +) -> Result { let p = session.catalog.photo(id).ok_or("no such photo")?; - let lightcraft_catalog::Source::File { path } = &p.source else { - return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); + let file_name = o.file_name_for_dated(&p.file_name, seq, p.captured.as_deref()); + let work = if o.format.is_rendered() { + let (w, h) = output_size(p, o); + let meta = export_metadata(p, o); + let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; + Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() })) + } else { + let lightcraft_catalog::Source::File { path } = &p.source else { + return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); + }; + Work::File { + path: path.clone(), + read: session.media.file_bytes.clone(), + packet: crate::sidecar::sidecar_packet(p), + dng: o.format == ExportFormat::Dng, + label: p.file_name.clone(), + size: (p.width as usize, p.height as usize), + } }; - let read = session.media.file_bytes.clone(); - let bytes = match &read { - Some(r) => r(path)?, - None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?, - }; - let packet = crate::sidecar::sidecar_packet(p); - let (width, height) = (p.width as usize, p.height as usize); - match o.format { - ExportFormat::Dng => { - if lightcraft_raw::probe(&bytes).is_none() { - return Err(format!("{}: DNG export needs a raw photo", p.file_name)); + Ok(PreparedExport { photo: id, file_name, work }) +} + +impl PreparedExport { + pub fn run(self) -> Result { + let file_name = self.file_name; + match self.work { + Work::Render(w) => { + let RenderWork { job, meta, opts } = *w; + let r = job.run().rendered?; + let bytes = encode_rendered(&r, &opts, meta.as_ref())?; + Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) + } + Work::File { path, read, packet, dng, label, size } => { + let bytes = match &read { + Some(r) => r(&path)?, + None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, + }; + if !dng { + return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] }); + } + if lightcraft_raw::probe(&bytes).is_none() { + return Err(format!("{label}: DNG export needs a raw photo")); + } + let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + drop(bytes); + let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() }) + .map_err(|e| e.to_string())?; + Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() }) } - let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{}: {e}", p.file_name))?; - let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), ..Default::default() }) - .map_err(|e| e.to_string())?; - Ok(Exported { file_name, bytes: dng, width: raw.width, height: raw.height, sidecars: Vec::new() }) } - _ => Ok(Exported { file_name, bytes, width, height, sidecars: vec![("xmp", packet.into_bytes())] }), } } /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy /// applied), or one exact file path (single photo; overwritten). -pub struct Destination<'a> { - pub dir: &'a str, - pub exact: Option<&'a str>, +#[derive(Clone, Debug, Default)] +pub struct Destination { + pub dir: String, + pub exact: Option, } -/// Export `ids` in order: render/encode each ([`export_photo`]), pick its path, and hand the -/// bytes (and sidecars) to `write`. `exists` tells whether a path is taken (conflict policy). -/// Returns one JSON object per photo: `{path, width, height, bytes, sidecars}` or -/// `{skipped: path}`. +/// Export `ids` in order ([`prepare_export`] + [`run_batch`]), stopping at the first error. pub fn export_batch( session: &mut crate::Session, ids: &[lightcraft_catalog::PhotoId], @@ -878,15 +929,45 @@ pub fn export_batch( to: &Destination, write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, exists: &dyn Fn(&str) -> bool, +) -> Result, String> { + let items = ids.iter().enumerate().map(|(i, id)| prepare_export(session, *id, o, i + 1)).collect::, _>>()?; + run_batch(items, o, to, write, exists, true, &mut |_, _| true) +} + +/// Run prepared exports in order: pick each one's path, and hand the bytes (and sidecars) to +/// `write`. `exists` tells whether a path is taken (conflict policy). `progress(done, next file)` +/// is called before each photo; returning false cancels the rest. Returns one JSON object per +/// photo: `{path, width, height, bytes, sidecars}`, `{skipped: path}` or (unless +/// `stop_on_error`) `{photo, file, error}`. +pub fn run_batch( + items: Vec, + o: &ExportOptions, + to: &Destination, + write: &mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &dyn Fn(&str) -> bool, + stop_on_error: bool, + progress: &mut dyn FnMut(usize, &str) -> bool, ) -> Result, String> { use serde_json::json; let join = |a: &str, b: &str| if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) }; - let dir = if o.subfolder.is_empty() { to.dir.to_string() } else { join(to.dir, &o.subfolder) }; + let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join(&to.dir, &o.subfolder) }; + let single = items.len() == 1; let mut taken = std::collections::HashSet::new(); let mut out = Vec::new(); - for (i, id) in ids.iter().enumerate() { - let e = export_photo(session, *id, o, i + 1)?; - let path = match to.exact.filter(|_| ids.len() == 1) { + for (i, item) in items.into_iter().enumerate() { + if !progress(i, &item.file_name) { + break; + } + let (photo, name) = (item.photo, item.file_name.clone()); + let e = match item.run() { + Ok(e) => e, + Err(err) if stop_on_error => return Err(err), + Err(err) => { + out.push(json!({"photo": photo.0, "file": name, "error": err})); + continue; + } + }; + let path = match to.exact.as_deref().filter(|_| single) { Some(p) => p.to_string(), None => { let mut path = join(&dir, &e.file_name); @@ -908,15 +989,23 @@ pub fn export_batch( path } }; - write(&path, &e.bytes)?; - let mut sidecars = Vec::new(); - for (ext, bytes) in &e.sidecars { - let sc = std::path::Path::new(&path).with_extension(ext).to_string_lossy().to_string(); - write(&sc, bytes)?; - sidecars.push(sc); + let written = write(&path, &e.bytes).and_then(|()| { + let mut sidecars = Vec::new(); + for (ext, bytes) in &e.sidecars { + let sc = std::path::Path::new(&path).with_extension(ext).to_string_lossy().to_string(); + write(&sc, bytes)?; + sidecars.push(sc); + } + Ok(sidecars) + }); + match written { + Ok(sidecars) => { + taken.insert(path.clone()); + out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); + } + Err(err) if stop_on_error => return Err(err), + Err(err) => out.push(json!({"photo": photo.0, "file": path, "error": err})), } - taken.insert(path.clone()); - out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); } Ok(out) } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 995fbfcf8..d85f23ec1 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -653,13 +653,25 @@ impl crate::Session { space: lightcraft_pipeline::OutputSpace, depth: lightcraft_pipeline::OutputDepth, ) -> Result { + let r = self.export_job(id, max_w, max_h, space, depth)?.run(); + self.accept(&r); + r.rendered + } + + /// The job [`Self::render_export`] runs, to run elsewhere (e.g. a background export thread). + pub fn export_job( + &mut self, + id: PhotoId, + max_w: usize, + max_h: usize, + space: lightcraft_pipeline::OutputSpace, + depth: lightcraft_pipeline::OutputDepth, + ) -> Result { let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; job.request.space = space; job.request.depth = depth; job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); - let r = job.run(); - self.accept(&r); - r.rendered + Ok(job) } /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 923a3ad8f..331005752 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -121,7 +121,7 @@ fn presets_conflicts_and_subfolders() { // a fake file system: what exists, what was written let existing = ["out/Sub/LC01347.png".to_string()]; - let to = Destination { dir: "out", exact: None }; + let to = Destination { dir: "out".into(), exact: None }; let batch = |s: &mut Session, ids: &[_], o: &ExportOptions| { let mut written: Vec = Vec::new(); let mut write = |p: &str, _: &[u8]| { @@ -143,3 +143,34 @@ fn presets_conflicts_and_subfolders() { let (_, written) = batch(&mut s, &ids[..1], &ExportOptions { conflict: crate::export::Conflict::Overwrite, ..o }); assert_eq!(written, ["out/Sub/LC01347.png"]); } + +#[test] +fn prepared_exports_run_on_another_thread() { + fn send(_: &T) {} + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 48})); + let items: Vec<_> = (1..=2).map(|seq| crate::export::prepare_export(&mut s, id, &o, seq).unwrap()).collect(); + send(&items); + let to = crate::export::Destination::default(); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let seen2 = seen.clone(); + let files = std::thread::spawn(move || { + let mut written = Vec::new(); + let mut write = |p: &str, b: &[u8]| { + written.push((p.to_string(), b.len())); + Ok(()) + }; + // cancel after the first photo + crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |done, name| { + seen2.lock().unwrap().push((done, name.to_string())); + done < 1 + }) + .unwrap() + }) + .join() + .unwrap(); + assert_eq!(files.len(), 1, "cancelled before the second: {files:?}"); + assert_eq!(files[0]["width"], 48); + assert_eq!(seen.lock().unwrap().len(), 2); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 643753146..58553529f 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -79,7 +79,10 @@ impl Headless { } std::fs::write(path, bytes).map_err(|err| format!("{path}: {err}")) }; - let files = export_batch(&mut self.session, &ids, &opts, &Destination { dir, exact }, write, &|path| Path::new(path).exists())?; + let files = + export_batch(&mut self.session, &ids, &opts, &Destination { dir: dir.to_string(), exact: exact.map(str::to_string) }, write, &|path| { + Path::new(path).exists() + })?; // Single-photo exports also report path/width/height at the top level (back-compat). let mut out = files.first().cloned().unwrap_or_else(|| json!({})); out["files"] = json!(files); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 68858d6f8..e8075de24 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -81,6 +81,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "loupe": app.loupe_shown.map(|(p, src)| json!({"photo": p.0, "source": src, "pending": app.renderer.is_pending(crate::render::Slot::Main)})), "hoverPreview": app.hover_preview.as_ref().map(|h| h.label.clone()), "status": app.ui.status, + "export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result}, "memory": memory(app), }) } @@ -342,9 +343,19 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result, _>>()?; + crate::export_task::start(app, items, opts, to)? + } else { + let w = app.services.write.as_mut().ok_or("no writer")?; + json!({"files": export_batch(&mut app.session, &ids, &opts, &to, &mut |path, bytes| w(path, bytes), &|path| std::path::Path::new(path).exists())?}) + }; // remember for Export with Previous (and to prefill the dialog) let mut last = p.clone(); if let Some(o) = last.as_object_mut() { @@ -354,7 +365,7 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result) -> Value { diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs new file mode 100644 index 000000000..6726c5f59 --- /dev/null +++ b/crates/ui-egui/src/export_task.rs @@ -0,0 +1,124 @@ +//! Background export: the Export dialog, File → Export with Preset and Export with Previous hand +//! their batch to a worker thread so the window stays responsive. Photos are prepared on the UI +//! thread ([`lightcraft_engine::export::prepare_export`]: cheap, needs the session) and rendered, +//! encoded and written on the worker; a progress panel shows the count and a Cancel button. +//! +//! Needs [`crate::Services::write_shared`] (a thread-safe writer); without it (web) the batch runs +//! synchronously as before. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, channel}; +use std::sync::{Arc, Mutex}; + +use egui::Align2; +use lightcraft_engine::export::{Destination, ExportOptions, PreparedExport, run_batch}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::theme::Tokens; + +pub struct ExportTask { + pub total: usize, + /// Photos started so far and the file in progress. + pub progress: Arc>, + pub cancel: Arc, + rx: Receiver, String>>, +} + +impl ExportTask { + /// `{total, done, current}` for `ui.inspect`. + pub fn status(&self) -> Value { + let (done, current) = self.progress.lock().map(|g| g.clone()).unwrap_or_default(); + json!({"total": self.total, "done": done, "current": current}) + } +} + +/// Start exporting `items` in the background. Errors per photo are collected, not fatal. +pub fn start(app: &mut LightcraftApp, items: Vec, opts: ExportOptions, to: Destination) -> Result { + if app.export.is_some() { + return Err("an export is already running".into()); + } + let write = app.services.write_shared.clone().ok_or("no background writer")?; + let total = items.len(); + let progress = Arc::new(Mutex::new((0, String::new()))); + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = channel(); + let (p, c) = (progress.clone(), cancel.clone()); + let work = move || { + let mut w = |path: &str, bytes: &[u8]| write(path, bytes); + let r = run_batch(items, &opts, &to, &mut w, &|path| std::path::Path::new(path).exists(), false, &mut |done, name| { + if let Ok(mut g) = p.lock() { + *g = (done, name.to_string()); + } + !c.load(Ordering::Relaxed) + }); + let _ = tx.send(r); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + work(); + app.export = Some(ExportTask { total, progress, cancel, rx }); + Ok(json!({"background": true, "total": total})) +} + +/// Per frame: draw the progress panel; when the batch finishes, report it. +pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(task) = &app.export else { return }; + match task.rx.try_recv() { + Ok(r) => { + let cancelled = task.cancel.load(Ordering::Relaxed); + let total = task.total; + app.export = None; + let msg = match r { + Ok(files) => { + let ok = files.iter().filter(|f| f.get("path").is_some()).count(); + let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect(); + let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count(); + let mut m = format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }); + if skipped > 0 { + m += &format!(" · {skipped} skipped (file exists)"); + } + if let Some(first) = failed.first() { + m += &format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error")); + } + if cancelled { + m += " · cancelled"; + } + app.last_export_result = Some(json!({"files": files, "cancelled": cancelled})); + m + } + Err(e) => e, + }; + app.toast(ctx, msg); + } + Err(std::sync::mpsc::TryRecvError::Empty) => { + let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); + let (total, cancel) = (task.total, task.cancel.clone()); + let t = Tokens::get(ctx); + egui::Window::new("Exporting") + .title_bar(false) + .resizable(false) + .anchor(Align2::LEFT_BOTTOM, [16.0, -56.0]) + .fixed_size([300.0, 64.0]) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(format!("Exporting {} of {total}", (done + 1).min(total))).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let stopping = cancel.load(Ordering::Relaxed); + if crate::widgets::text_button(ui, "exportCancel", if stopping { "Stopping…" } else { "Cancel" }, false).clicked() { + cancel.store(true, Ordering::Relaxed); + } + }); + }); + ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(280.0)); + ui.label(egui::RichText::new(current).size(11.0).color(t.text_dim)); + }); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + app.export = None; + app.toast(ctx, "Export stopped unexpectedly"); + } + } +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index aeed7dc16..8d9c94fba 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -174,6 +174,7 @@ impl Headless { self.app.renderer.in_flight() > 0 || self.app.merge.busy() || self.app.import.is_some() + || self.app.export.is_some() || !self.app.synthetic.is_empty() || !self.events.is_empty() } @@ -550,6 +551,46 @@ mod tests { h.settle(SETTLE); } + /// The Export dialog hands its batch to a worker thread: the UI keeps drawing frames, shows + /// progress, and reports the result when the files are written. + #[test] + fn export_dialog_runs_in_the_background() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let written = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let w = written.clone(); + h.app.services.write_shared = Some(std::sync::Arc::new(move |p: &str, _: &[u8]| { + std::thread::sleep(Duration::from_millis(150)); // a slow disk: the UI must not wait for it + w.lock().unwrap().push(p.to_string()); + Ok(()) + })); + let ids: Vec = h.app.session.visible_cloned().iter().take(3).map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), t); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + h.settle(SETTLE); + if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog { + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(64); + *dir = "/lc-test-out".into(); + } + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.export.is_some(), "running in the background"); + let running = h.request("ui.inspect", json!({}), t); + assert_eq!(running["result"]["export"]["running"]["total"], 3, "{}", running["result"]["export"]); + let t0 = Instant::now(); + while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) { + h.step(); + } + assert!(h.app.export.is_none(), "finished"); + let w = written.lock().unwrap().clone(); + assert_eq!(w.len(), 3, "{w:?}"); + assert!(w.iter().all(|p| p.starts_with("/lc-test-out/"))); + let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone(); + assert_eq!(last["files"].as_array().map(Vec::len), Some(3), "{last}"); + assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64)); + } + /// ⇧⌘V opens Paste Selected Settings (prefilled with the copied groups); unchecking a group /// leaves it alone. ⌘F puts typing into the search field. #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4419a490e..1136526cb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] pub mod control; +pub mod export_task; pub mod headless; pub mod icons; pub mod import; @@ -36,6 +37,8 @@ pub type PickFiles = Box Vec>; /// A save dialog: suggested file name → chosen path (`None` = cancelled). pub type SaveFile = Box Option>; pub type WriteFn = Box Result<(), String>>; +/// A writer other threads can use (background export). +pub type SharedWrite = std::sync::Arc Result<(), String> + Send + Sync>; pub type PngEncode = Box Vec>; /// A folder chooser (`None` = cancelled). pub type PickFolder = Box Option>; @@ -54,6 +57,8 @@ pub struct Services { /// Save dialog for an exported `.lcpreset` file. pub save_preset_file: Option, pub write: Option, + /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). + pub write_shared: Option, /// PNG encoder (the host links an image encoder; the UI crate stays codec-free). pub png: Option, /// Show a file in the system file manager (desktop only). @@ -109,6 +114,10 @@ pub struct LightcraftApp { pub merge: merge::MergeState, /// An import in progress (the import review dialog's batches). pub import: Option, + /// A background export in progress. + pub export: Option, + /// The files of the last finished background export (`ui.inspect` → `export.last`). + pub last_export_result: Option, /// The look the loupe shows while the pointer rests on a preset or profile (set by the /// panels each frame; nothing is committed, no history entry). pub hover_preview: Option, @@ -149,6 +158,8 @@ impl LightcraftApp { loupe_shown: None, merge: merge::MergeState::default(), import: None, + export: None, + last_export_result: None, hover_preview: None, window_is_fullscreen: false, gpu_applied: None, @@ -429,6 +440,7 @@ impl LightcraftApp { }); panels::dialogs::show(self, &ctx); import::progress(self, &ctx); + export_task::poll(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.perf.frame_ms = now_ms() - t0; diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 14936f988..d54e99a84 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -348,7 +348,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(MenuNode::Separator); } builtin = b; - v.push(item("app.export", json!({"preset": p.name}), p.name, None, sel, None)); + v.push(item("app.export", json!({"preset": p.name, "background": true}), p.name, None, sel, None)); } v.push(MenuNode::Separator); v.push(item("dialog.export", Value::Null, "Custom…", None, sel, None)); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index d48d6dd9d..62bbdb852 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -602,6 +602,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { let mut p = export_dialog_params(opts, *full_size, resize, *limit_kb); p["dir"] = json!(dir); + p["background"] = json!(true); app.run("app.export", p) } Dialog::Merge { opts } => crate::merge::start_final(app, opts), diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 9008515db..3558d8d04 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -540,7 +540,8 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.menu_button("Export with Preset", |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { - match app.run("app.export", json!({"preset": p.name})) { + match app.run("app.export", json!({"preset": p.name, "background": true})) { + Ok(r) if r.get("background").is_some() => {} Ok(r) => { let n = r["files"].as_array().map_or(0, Vec::len); app.toast(ui.ctx(), format!("Exported {n} photo{}", if n == 1 { "" } else { "s" })); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15ee1fd87..2b4c98842 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -21,7 +21,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status, memory (bytes per cache, see `library.memory`; plus stage caches and textures) | +| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}` | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | @@ -33,6 +33,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | | `ui.resize` | `{width, height}` | Resize the window | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | +| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}`; with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `app.quit` | — | Close the app | diff --git a/docs/parity.md b/docs/parity.md index 8a4ee7294..557525e13 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,11 +44,11 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 10 | 3 | 4 | 1 | 7/8 (88%) | 3/5 (60%) | +| X. Cross-cutting behaviours (BEHAV) | 11 | 2 | 4 | 1 | 8/8 (100%) | 3/5 (60%) | | Y. Menus | 56 | 16 | 13 | 8 | 41/47 (87%) | 13/23 (57%) | | Z. Keyboard shortcuts (desktop) | 56 | 13 | 11 | 1 | 47/52 (90%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 20 | 55 | 9 | — | 3/21 (14%) | -| **Total** | 264 | 80 | 156 | 37 | 182/198 (92%) | 72/144 (50%) | +| **Total** | 265 | 79 | 156 | 37 | 183/198 (92%) | 72/144 (50%) | ## Top gaps @@ -474,7 +474,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | 🟡 | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/detail.rs` | presets, profile menu and profile browser preview in the loupe; versions do not | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | 🟡 | `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; no progress popover for import/export | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`); imports show a progress window. No combined activity centre | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | 🟡 | `crates/ui-egui/src/lib.rs` | files → app only; no photos → albums |