From e9e8b5a9330eae0232ae8ebdd871cf340baa418f Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Sat, 10 Oct 2026 01:13:53 +0200 Subject: [PATCH] Issue #345: photo merge in the activity stack The final HDR / panorama merge shows a row with its percentage and stage instead of the progress toast, and stops from its cross ("HDR merge cancelled"). The activity tests now let the first renders finish before they run, and the half-second check tolerates a slow frame. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/lightroom_import.rs | 3 +- crates/ui-egui/src/merge.rs | 21 ++++++-- crates/ui-egui/src/tests_activity.rs | 53 +++++++++++++++++++-- 23 files changed, 106 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8037a6f8..0ad4ab843 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 d67494b78..f7dbc65ab 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 3597ffdc9..671da17d4 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 598ace824..1edf29adc 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 ea5021984..9ba1c8f10 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 f0740bd52..701c91bdb 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 f39076898..7ccaf5a38 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 51dab1b24..59631e84a 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 f42118d53..f1c06351c 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 a10a08e1e..520452a8d 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 8bd5bcae6..4a9d1168a 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 d9d07b5fd..ad269f963 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 53a54f6cc..ba3560ad0 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 3a442515a..6a4e4d528 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 9562c8f90..c33dd0cb0 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 a18094f4d..3c765a1ba 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 cf28c11aa..eb429af42 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 c2c29b478..24a47421c 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 61e7cef81..0b2ec7657 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 e808c3120..caf5f9bca 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 b05b381c9..28101ed2c 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 3849a191b..b1d066a7b 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 5e8e967d0..db4b58297 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); +}