mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-11 16:31:32 -06:00
LR-BEHAV-BG-TASKS: background export with progress and cancel
- Engine: export split into prepare_export (session thread, cheap) and PreparedExport::run
(Send: read/decode/render/encode); run_batch with per-photo progress, cancel and collected
errors; export_batch keeps the stop-at-first-error behaviour for scripted calls.
- Desktop: the Export dialog, Export with Preset (menu and context menu) and Export with
Previous run their batch on a worker thread (app.export {background: true}); a progress
panel shows count / file / Cancel; ui.inspect reports export {running, last}.
- Headless: settle waits for a running export; `lightcraft-cli snapshot` finishes it before
exiting. Web keeps foreground exports (downloads are main-thread).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8548c157ba
commit
8a83f8c6ce
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -155,6 +155,8 @@ fn services(originals: Originals, backend: Option<Backend>, 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()
|
||||
})),
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
+139
-50
@@ -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<Exported, String> {
|
||||
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<Exported, String> {
|
||||
/// 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<Metadata>,
|
||||
opts: ExportOptions,
|
||||
}
|
||||
|
||||
enum Work {
|
||||
Render(Box<RenderWork>),
|
||||
/// [`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<crate::merge::ByteReader>,
|
||||
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<PreparedExport, String> {
|
||||
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<Exported, String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<Vec<serde_json::Value>, String> {
|
||||
let items = ids.iter().enumerate().map(|(i, id)| prepare_export(session, *id, o, i + 1)).collect::<Result<Vec<_>, _>>()?;
|
||||
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<PreparedExport>,
|
||||
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<Vec<serde_json::Value>, 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)
|
||||
}
|
||||
|
||||
@@ -653,13 +653,25 @@ impl crate::Session {
|
||||
space: lightcraft_pipeline::OutputSpace,
|
||||
depth: lightcraft_pipeline::OutputDepth,
|
||||
) -> Result<Rendered, String> {
|
||||
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<RenderJob, String> {
|
||||
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.
|
||||
|
||||
@@ -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<String> = 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: 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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Value, String
|
||||
// no folder given (e.g. File → Export with Preset): the last export's, else the default
|
||||
let last_dir = app.session.last_export.as_ref().and_then(|l| l.get("dir")).and_then(Value::as_str).map(str::to_string);
|
||||
let dir = p.get("dir").and_then(Value::as_str).map(str::to_string).or(last_dir).filter(|d| !d.is_empty()).unwrap_or_else(default_export_dir);
|
||||
let to = Destination { dir: &dir, exact: p.get("path").and_then(Value::as_str) };
|
||||
let w = app.services.write.as_mut().ok_or("no writer")?;
|
||||
let out = export_batch(&mut app.session, &ids, &opts, &to, &mut |path, bytes| w(path, bytes), &|path| std::path::Path::new(path).exists())?;
|
||||
let to = Destination { dir: dir.clone(), exact: p.get("path").and_then(Value::as_str).map(str::to_string) };
|
||||
let background = p.get("background").and_then(Value::as_bool).unwrap_or(false) && app.services.write_shared.is_some();
|
||||
let out = if background {
|
||||
let items = ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, id)| lightcraft_engine::export::prepare_export(&mut app.session, *id, &opts, i + 1))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
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, String
|
||||
}
|
||||
app.session.last_export = Some(last);
|
||||
let _ = app.session.save_prefs();
|
||||
Ok(json!({"files": out}))
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
|
||||
@@ -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<Mutex<(usize, String)>>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
rx: Receiver<Result<Vec<Value>, 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<PreparedExport>, opts: ExportOptions, to: Destination) -> Result<Value, String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::<String>::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<u64> = 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]
|
||||
|
||||
@@ -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<dyn FnMut() -> Vec<String>>;
|
||||
/// A save dialog: suggested file name → chosen path (`None` = cancelled).
|
||||
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
/// A writer other threads can use (background export).
|
||||
pub type SharedWrite = std::sync::Arc<dyn Fn(&str, &[u8]) -> Result<(), String> + Send + Sync>;
|
||||
pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
|
||||
/// A folder chooser (`None` = cancelled).
|
||||
pub type PickFolder = Box<dyn FnMut() -> Option<String>>;
|
||||
@@ -54,6 +57,8 @@ pub struct Services {
|
||||
/// Save dialog for an exported `.lcpreset` file.
|
||||
pub save_preset_file: Option<SaveFile>,
|
||||
pub write: Option<WriteFn>,
|
||||
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
|
||||
pub write_shared: Option<SharedWrite>,
|
||||
/// PNG encoder (the host links an image encoder; the UI crate stays codec-free).
|
||||
pub png: Option<PngEncode>,
|
||||
/// 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<import::ImportTask>,
|
||||
/// A background export in progress.
|
||||
pub export: Option<export_task::ExportTask>,
|
||||
/// The files of the last finished background export (`ui.inspect` → `export.last`).
|
||||
pub last_export_result: Option<Value>,
|
||||
/// 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<HoverPreview>,
|
||||
@@ -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;
|
||||
|
||||
@@ -348,7 +348,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
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));
|
||||
|
||||
@@ -602,6 +602,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => {
|
||||
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),
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
+3
-3
@@ -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%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## 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 |
|
||||
|
||||
Reference in New Issue
Block a user