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 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); +}