diff --git a/Cargo.lock b/Cargo.lock index e8037a6f..0ad4ab84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3352,6 +3352,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-faces", "lightcraft-geom", + "lightcraft-merge", "lightcraft-meta", "lightcraft-pipeline", "lightcraft-preview", diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d67494b7..f7dbc65a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -28,6 +28,7 @@ log = { workspace = true } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } lightcraft-faces = { workspace = true } +lightcraft-merge = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 3597ffdc..671da17d 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "+{n} weitere", "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", - "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab." + "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", + "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 598ace82..1edf29ad 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2026,5 +2026,6 @@ "Building smart previews": "Smart-Vorschauen werden erstellt", "Discarding smart previews": "Smart-Vorschauen werden verworfen", "Importing Lightroom catalog": "Lightroom-Katalog wird importiert", - "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen" + "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen", + "Merging": "Zusammenfügen" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index ea502198..9ba1c8f1 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "+{n} más", "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", - "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene." + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", + "{what} merge cancelled": "Combinación {what} cancelada" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index f0740bd5..701c91bd 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1871,5 +1871,6 @@ "Building smart previews": "Generando vistas previas inteligentes", "Discarding smart previews": "Descartando vistas previas inteligentes", "Importing Lightroom catalog": "Importando catálogo de Lightroom", - "Reading Lightroom catalog": "Leyendo catálogo de Lightroom" + "Reading Lightroom catalog": "Leyendo catálogo de Lightroom", + "Merging": "Combinando" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index f3907689..7ccaf5a3 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "+{n} autres", "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", - "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête." + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", + "{what} merge cancelled": "Fusion {what} annulée" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 51dab1b2..59631e84 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1871,5 +1871,6 @@ "Building smart previews": "Génération des aperçus dynamiques", "Discarding smart previews": "Suppression des aperçus dynamiques", "Importing Lightroom catalog": "Importation du catalogue Lightroom", - "Reading Lightroom catalog": "Lecture du catalogue Lightroom" + "Reading Lightroom catalog": "Lecture du catalogue Lightroom", + "Merging": "Fusion en cours" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index f42118d5..f1c06351 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct}%", "+{n} more": "ほか{n}件", "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", - "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。" + "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。", + "{what} merge cancelled": "{what}合成をキャンセルしました" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a10a08e1..520452a8 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1873,5 +1873,6 @@ "Building smart previews": "スマートプレビューを作成中", "Discarding smart previews": "スマートプレビューを破棄中", "Importing Lightroom catalog": "Lightroom カタログを読み込み中", - "Reading Lightroom catalog": "Lightroom カタログを読み取り中" + "Reading Lightroom catalog": "Lightroom カタログを読み取り中", + "Merging": "合成中" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 8bd5bcae..4a9d1168 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct}%", "+{n} more": "+{n} mais", "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", - "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe." + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", + "{what} merge cancelled": "{what} mesclagem cancelada" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index d9d07b5f..ad269f96 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1871,5 +1871,6 @@ "Building smart previews": "Gerando prévias inteligentes", "Discarding smart previews": "Descartando prévias inteligentes", "Importing Lightroom catalog": "Importando catálogo do Lightroom", - "Reading Lightroom catalog": "Lendo catálogo do Lightroom" + "Reading Lightroom catalog": "Lendo catálogo do Lightroom", + "Merging": "Mesclando" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 53a54f6c..ba3560ad 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "ещё {n}", "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", - "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их." + "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.", + "{what} merge cancelled": "{what}: слияние отменено" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 3a442515..6a4e4d52 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1584,5 +1584,6 @@ "Building smart previews": "Создание смарт-просмотров", "Discarding smart previews": "Удаление смарт-просмотров", "Importing Lightroom catalog": "Импорт каталога Lightroom", - "Reading Lightroom catalog": "Чтение каталога Lightroom" + "Reading Lightroom catalog": "Чтение каталога Lightroom", + "Merging": "Слияние" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 9562c8f9..c33dd0cb 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct} %", "+{n} more": "+{n} ще", "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", - "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх." + "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.", + "{what} merge cancelled": "Об’єднання {what} скасовано" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index a18094f4..3c765a1b 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2065,5 +2065,6 @@ "Building smart previews": "Створення смарт-переглядів", "Discarding smart previews": "Видалення смарт-переглядів", "Importing Lightroom catalog": "Імпорт каталогу Lightroom", - "Reading Lightroom catalog": "Читання каталогу Lightroom" + "Reading Lightroom catalog": "Читання каталогу Lightroom", + "Merging": "Об’єднання" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index cf28c11a..eb429af4 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct}%", "+{n} more": "还有 {n} 项", "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", - "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。" + "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。", + "{what} merge cancelled": "已取消 {what} 合并" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index c2c29b47..24a47421 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1871,5 +1871,6 @@ "Building smart previews": "正在构建智能预览", "Discarding smart previews": "正在丢弃智能预览", "Importing Lightroom catalog": "正在导入 Lightroom 目录", - "Reading Lightroom catalog": "正在读取 Lightroom 目录" + "Reading Lightroom catalog": "正在读取 Lightroom 目录", + "Merging": "正在合并" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 61e7cef8..0b2ec765 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -227,5 +227,6 @@ "{pct} %": "{pct}%", "+{n} more": "還有 {n} 項", "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", - "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。" + "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", + "{what} merge cancelled": "已取消{what}合併" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index e808c312..caf5f9bc 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1871,5 +1871,6 @@ "Building smart previews": "正在建立智慧型預覽", "Discarding smart previews": "正在捨棄智慧型預覽", "Importing Lightroom catalog": "正在匯入 Lightroom 目錄", - "Reading Lightroom catalog": "正在讀取 Lightroom 目錄" + "Reading Lightroom catalog": "正在讀取 Lightroom 目錄", + "Merging": "正在合併" } diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index b05b381c..28101ed2 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -468,7 +468,8 @@ mod tests { fn demo() -> crate::headless::Headless { let app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); - h.step(); + // no render left running at exit (see tests_activity::demo) + h.settle(std::time::Duration::from_secs(20)); h } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index 3849a191..b1d066a7 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -4,7 +4,7 @@ //! Previews and the final merge run [`lightcraft_engine::merge::MergeJob`]s on worker threads with //! progress and cancellation; the window stays responsive. Changing an option cancels the running //! preview and starts a new one. The final merge keeps running after the dialog closes (progress -//! in a toast); when it finishes, the result is written, imported and selected +//! in the activity stack); when it finishes, the result is written, imported and selected //! ([`lightcraft_engine::Session::finish_merge`]). use std::sync::atomic::{AtomicBool, Ordering}; @@ -12,6 +12,7 @@ use std::sync::mpsc::{Receiver, channel}; use std::sync::{Arc, Mutex}; use lightcraft_catalog::PhotoId; +use lightcraft_engine::activity::{Cancel, TaskGuard, Unit}; use lightcraft_engine::merge::{MergeJob, MergeOutput}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -88,6 +89,8 @@ pub struct MergeTask { pub progress: Arc>, pub cancel: Arc, rx: Receiver>, + /// The final merge's row in the activity stack (previews have none). + guard: Option, } /// Merge state of the app. @@ -140,7 +143,7 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b std::thread::Builder::new().name("photo-merge".into()).spawn(work).map_err(|e| e.to_string())?; #[cfg(target_arch = "wasm32")] work(); - Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx }) + Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None }) } /// Open the merge dialog for the selection. @@ -169,7 +172,11 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)), Err(e) => { app.ui.status = e.to_string(); app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)); @@ -273,8 +281,11 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); - let now = ctx.input(|i| i.time); - app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5, None)); + if let Some(guard) = &t.guard { + let f = if f.is_finite() { f.clamp(0.0, 1.0) } else { 0.0 }; + guard.progress((f * 1000.0) as u64, 1000); + guard.detail(crate::i18n::tr(&stage)); + } ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 5e8e967d..db4b5829 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -14,7 +14,9 @@ const T: Duration = Duration::from_secs(20); fn demo() -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - h.step(); + // let the first renders finish: a render still running when the test binary exits can crash in the + // GPU driver's teardown (the preview pool doesn't join its workers) + h.settle(T); h } @@ -53,7 +55,10 @@ fn rows_appear_after_half_a_second_and_cancel_by_click() { let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); g.progress(2, 5); h.step(); - assert!(!has(&h, &format!("activity:row:{}", g.id())), "not before 0.5 s"); + // a frame on a loaded machine can outlast the half second: only judge a task still young after it + if h.app.session.activity.list()[0].age_ms < crate::panels::activity::SHOW_AFTER_MS { + assert!(!has(&h, &format!("activity:row:{}", g.id())), "not before 0.5 s"); + } wait_visible(&mut h); assert!(has(&h, &format!("activity:row:{}", g.id()))); click(&mut h, &format!("activity:cancel:{}", g.id())); @@ -242,7 +247,7 @@ fn slow_import(tag: &str, n: usize) -> (Headless, std::path::PathBuf) { }) })); let mut h = Headless::new(LightcraftApp::new(s, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); - h.step(); + h.settle(T); (h, dir) } @@ -282,3 +287,45 @@ fn scan_shows_a_row_and_cancel_closes_it() { assert!(h.app.ui.dialog.is_none(), "no review opened"); let _ = std::fs::remove_dir_all(&dir); } + +/// A library of three bracketed DNGs (−2, 0, +2 EV) on disk, all selected. +fn bracket(tag: &str) -> (Headless, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("lc-activity-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut paths = Vec::new(); + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(T); + (h, dir) +} + +#[test] +fn final_merge_shows_a_row_and_cancels() { + let (mut h, dir) = bracket("merge"); + let before = h.app.session.catalog.len(); + crate::merge::start_last(&mut h.app, "merge.hdr").unwrap(); + h.step(); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].unit), ("merge", "Merging", Unit::Percent)); + assert!(rows[0].cancellable); + assert_eq!(rows[0].total, 1000); + h.app.session.activity.cancel(rows[0].id).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + assert!(h.app.merge.last_result.is_none(), "nothing merged"); + assert_eq!(h.app.session.catalog.len(), before, "no photo added"); + assert!(h.app.session.activity.list().is_empty()); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert_eq!(toast, "HDR merge cancelled"); + let _ = std::fs::remove_dir_all(dir); +}