diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index a7deebfb..16317f94 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -652,9 +652,16 @@ pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOp /// Oriented size of the embedded preview of a raw file. fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> { let jpeg = stand_in_image(bytes)?; - let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; - let (mut w, mut h) = (d.source_width, d.source_height); - if preview_orientation(bytes, d.orientation).swaps_axes() { + // headers only, as for other images (issue #367): decoding a full-size preview just for its + // size took about a second per file in an import; a decode remains the fallback + let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) { + Ok(header) => (header.width, header.height, header.orientation), + Err(_) => { + let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?; + (d.source_width, d.source_height, d.orientation) + } + }; + if preview_orientation(bytes, orientation).swaps_axes() { std::mem::swap(&mut w, &mut h); } Some((w, h)) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 20d1983e..24b0cb3a 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -466,10 +466,18 @@ pub fn launch_path(arg: &str, cwd: Option<&Path>, home: Option<&Path>, dcim: imp /// Probe files (headers + content hash) as an import would. pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec> { - probe_all(s.media.file_probe.as_ref(), paths, &ScanProgress::default()) + let progress = ScanProgress::default(); + probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) } -fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress: &ScanProgress) -> Vec> { +/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once +/// `cancel` is set: the files not reached come back as errors. +fn probe_all( + probe: Option<&crate::media::FileProbe>, + paths: &[String], + done: &std::sync::atomic::AtomicUsize, + cancel: &std::sync::atomic::AtomicBool, +) -> Vec> { use std::sync::atomic::Ordering::Relaxed; let Some(probe) = probe.cloned() else { return paths @@ -497,11 +505,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress if i >= paths.len() { break; } - if progress.cancel.load(Relaxed) { + if cancel.load(Relaxed) { break; } let r = probe(&paths[i]); - progress.done.fetch_add(1, Relaxed); + done.fetch_add(1, Relaxed); out.lock().unwrap_or_else(|e| e.into_inner())[i] = Some(r); } }); @@ -513,11 +521,11 @@ fn probe_all(probe: Option<&crate::media::FileProbe>, paths: &[String], progress paths .iter() .map(|p| { - if progress.cancel.load(Relaxed) { + if cancel.load(Relaxed) { return Err("cancelled".into()); } let r = probe(p); - progress.done.fetch_add(1, Relaxed); + done.fetch_add(1, Relaxed); r }) .collect() @@ -620,7 +628,7 @@ pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress .collect(); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); progress.done.store(todo.len() - missing.len(), Relaxed); - let mut fresh = probe_all(input.probe.as_ref(), &missing, progress).into_iter(); + let mut fresh = probe_all(input.probe.as_ref(), &missing, &progress.done, &progress.cancel).into_iter(); let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); @@ -705,6 +713,10 @@ pub struct ImportJob { /// Files found so far (folders expanded) and handled so far, for progress. pub total: std::sync::Arc, pub done: std::sync::Arc, + /// Of those, the files that have to be read first (not probed by a preceding scan) and the + /// ones read so far: the step before they are handled. + pub read_total: std::sync::Arc, + pub read_done: std::sync::Arc, } /// The outcome of [`ImportJob::prepare`] for one batch, in file order. @@ -857,6 +869,8 @@ impl ImportJob { now: (s.clock)(), total: Default::default(), done: Default::default(), + read_total: Default::default(), + read_done: Default::default(), }) } @@ -917,8 +931,10 @@ impl ImportJob { }) .collect(); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); - let progress = ScanProgress::default(); - let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter(); + // Reading the files is the slow part of adding them where they are: it is counted on its + // own, and `cancel` stops it too (a large library takes long enough to want both). + self.read_total.fetch_add(missing.len(), Relaxed); + let mut fresh = probe_all(self.probe.as_ref(), &missing, &self.read_done, cancel).into_iter(); let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); @@ -1498,4 +1514,51 @@ mod prepared_tests { assert!(matches!(prepared.items.first(), Some(PreparedItem::Duplicate { reason: "path", .. }))); assert!(matches!(prepared.items.get(1), Some(PreparedItem::Ready(_)))); } + + fn unread_paths(n: usize) -> Vec { + (0..n).map(|i| std::env::temp_dir().join(format!("lightcraft-probe-progress-{i}.jpg")).to_string_lossy().into()).collect() + } + + /// Reading the files of a large import (a Lightroom catalog's originals) reported nothing: + /// the progress stood still at "14 of 24554" for as long as it took. + #[test] + fn prepare_counts_the_files_it_reads() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (seen_in_probe, done_in_probe) = (seen.clone(), std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0))); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (done, last) = (job.read_done.clone(), done_in_probe.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + last.fetch_max(done.load(Relaxed), Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(40), &std::sync::atomic::AtomicBool::new(false)); + assert_eq!(prepared.items.len(), 40); + assert_eq!(seen.load(Relaxed), 40); + assert!(done_in_probe.load(Relaxed) > 0, "progress moves while files are read"); + assert_eq!((job.read_done.load(Relaxed), job.read_total.load(Relaxed)), (40, 40), "every file read is counted"); + assert_eq!((job.done.load(Relaxed), job.total.load(Relaxed)), (40, 40), "handling them is counted on its own"); + } + + /// Cancel had no effect until every file had been read. + #[test] + fn cancel_stops_reading_files() { + use std::sync::atomic::Ordering::Relaxed; + let mut s = Session::new(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut job = ImportJob::new(&mut s, ImportOptions { mode: ImportMode::Add, ..ImportOptions::default() }).unwrap(); + let (seen_in_probe, cancel_in_probe) = (seen.clone(), cancel.clone()); + job.probe = Some(std::sync::Arc::new(move |_: &str| { + seen_in_probe.fetch_add(1, Relaxed); + cancel_in_probe.store(true, Relaxed); + Ok(ProbeInfo { format: "JPEG".into(), ..ProbeInfo::default() }) + })); + let prepared = job.prepare(&unread_paths(400), &cancel); + // at most the files the (up to 8) readers had already started + assert!(seen.load(Relaxed) <= 8, "read {} files after cancel", seen.load(Relaxed)); + assert!(prepared.items.is_empty()); + } } diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 33ed64a2..ffd6fc18 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -129,6 +129,65 @@ pub struct LightroomCompletion { /// Alias used by hosts that call the result a commit. pub type LightroomCommit = LightroomCompletion; +/// The step of preparing a Lightroom import that is under way ([`LightroomProgress::step`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LightroomStep { + /// Reading the catalog's tables. + Catalog, + /// Reading the photos the catalog names (headers, content hash): nearly all of the time. + ReadPhotos, + /// Readying the photos that were read for the catalog. + AddPhotos, +} + +/// Progress of [`LightroomJob::prepare`] step by step, shared with the thread that runs it. +#[derive(Clone, Default)] +pub struct LightroomProgress { + /// Catalog tables to read and read so far ([`lightroom_catalog::read_with_progress`]). + pub catalog_total: Arc, + pub catalog_done: Arc, + read_total: Arc, + read_done: Arc, + add_total: Arc, + add_done: Arc, + /// The catalog is read: the photos are next. + photos: Arc, +} + +impl LightroomProgress { + /// Reading the photos is nearly all of an import's time; the catalog and readying the photos + /// take the rest of [`LightroomProgress::fraction`]. + const CATALOG_SHARE: f32 = 0.05; + const READ_SHARE: f32 = 0.85; + + /// The step under way, with how much of it is done and its total (0 while not yet known). + pub fn step(&self) -> (LightroomStep, usize, usize) { + let load = |n: &AtomicUsize| n.load(Ordering::Relaxed); + if !self.photos.load(Ordering::Relaxed) { + return (LightroomStep::Catalog, load(&self.catalog_done), load(&self.catalog_total)); + } + let (read_done, read_total) = (load(&self.read_done), load(&self.read_total)); + let (add_done, add_total) = (load(&self.add_done), load(&self.add_total)); + // nothing to read (every photo is already in the library) goes straight to adding + if read_done < read_total || (read_total == 0 && add_done == 0) { + (LightroomStep::ReadPhotos, read_done, read_total) + } else { + (LightroomStep::AddPhotos, add_done, add_total) + } + } + + /// The whole preparation, 0..=1. + pub fn fraction(&self) -> f32 { + let (step, done, total) = self.step(); + let part = if total == 0 { 0.0 } else { (done as f32 / total as f32).clamp(0.0, 1.0) }; + match step { + LightroomStep::Catalog => Self::CATALOG_SHARE * part, + LightroomStep::ReadPhotos => Self::CATALOG_SHARE + Self::READ_SHARE * part, + LightroomStep::AddPhotos => Self::CATALOG_SHARE + Self::READ_SHARE + (1.0 - Self::CATALOG_SHARE - Self::READ_SHARE) * part, + } + } +} + /// A Lightroom import detached from the owner thread. pub struct LightroomJob { path: PathBuf, @@ -136,6 +195,7 @@ pub struct LightroomJob { import: ImportJob, token: LightroomLibraryToken, archive_dir: Option, + progress: LightroomProgress, } impl LightroomJob { @@ -145,33 +205,25 @@ impl LightroomJob { let opts = ImportOptions { mode: ImportMode::Add, on_deleted: crate::import::OnDeleted::Skip, ..ImportOptions::default() }; let import = ImportJob::new(s, opts)?; let archive_dir = s.library.as_ref().filter(|library| library.on_disk).map(|library| library.dir.join("Interop")); - Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir }) + let progress = LightroomProgress { + read_total: Arc::clone(&import.read_total), + read_done: Arc::clone(&import.read_done), + add_total: Arc::clone(&import.total), + add_done: Arc::clone(&import.done), + ..LightroomProgress::default() + }; + Ok(Self { path, update_existing, import, token: LightroomLibraryToken::capture(s), archive_dir, progress }) } - pub fn total(&self) -> usize { - self.import.total.load(Ordering::Relaxed) - } - - pub fn done(&self) -> usize { - self.import.done.load(Ordering::Relaxed) - } - - pub fn progress(&self) -> (usize, usize) { - (self.done(), self.total()) + /// The job's progress, to watch from another thread while [`LightroomJob::prepare`] runs. + pub fn progress(&self) -> LightroomProgress { + self.progress.clone() } pub fn token(&self) -> &LightroomLibraryToken { &self.token } - pub fn total_atomic(&self) -> std::sync::Arc { - std::sync::Arc::clone(&self.import.total) - } - - pub fn done_atomic(&self) -> std::sync::Arc { - std::sync::Arc::clone(&self.import.done) - } - /// Finish an owner commit's deferred archive-index write on a worker. pub fn finish_archive(finalization: LightroomArchiveFinalization) -> crate::Result<()> { finalization.finish() @@ -182,14 +234,12 @@ impl LightroomJob { if cancel.load(Ordering::Relaxed) { return Err(error("Lightroom import cancelled")); } - self.import.total.store(1, Ordering::Relaxed); - self.import.done.store(0, Ordering::Relaxed); - let mut data = lightroom_catalog::read_with_progress(&self.path, cancel, &self.import.total, &self.import.done).map_err(error)?; + let mut data = + lightroom_catalog::read_with_progress(&self.path, cancel, &self.progress.catalog_total, &self.progress.catalog_done).map_err(error)?; lightroom_catalog::validate_for_job(&data).map_err(error)?; if cancel.load(Ordering::Relaxed) { return Err(error("Lightroom import cancelled")); } - self.import.done.fetch_add(1, Ordering::Relaxed); let paths: Vec = data .photos .iter() @@ -203,6 +253,9 @@ impl LightroomJob { .filter(|photo| !Path::new(&photo.path).is_file()) .map(|photo| normalize(&photo.path)) .collect(); + // the catalog step's last unit (validation, and looking for each original), then the photos + self.progress.catalog_done.fetch_add(1, Ordering::Relaxed); + self.progress.photos.store(true, Ordering::Relaxed); let normal = self.import.prepare(&paths, cancel); if cancel.load(Ordering::Relaxed) { normal.rollback(); @@ -335,6 +388,48 @@ mod tests { } } + /// One count for the whole import read "14 of 24554" while the photos were read, then twice + /// the number of photos: each step has its own count, and the whole only moves forward. + #[test] + fn progress_names_each_step_with_its_own_count() { + let p = LightroomProgress::default(); + let set = |n: &AtomicUsize, v: usize| n.store(v, Ordering::Relaxed); + assert_eq!(p.step(), (LightroomStep::Catalog, 0, 0)); + assert_eq!(p.fraction(), 0.0); + set(&p.catalog_total, 14); + set(&p.catalog_done, 7); + assert_eq!(p.step(), (LightroomStep::Catalog, 7, 14)); + let mut last = p.fraction(); + assert!(last > 0.0); + let mut forward = |p: &LightroomProgress| { + let f = p.fraction(); + assert!(f >= last && f <= 1.0, "{f} after {last}"); + last = f; + }; + set(&p.catalog_done, 14); + p.photos.store(true, Ordering::Relaxed); + assert_eq!(p.step(), (LightroomStep::ReadPhotos, 0, 0), "the photos are not counted yet"); + forward(&p); + set(&p.add_total, 100); + set(&p.read_total, 100); + set(&p.read_done, 40); + assert_eq!(p.step(), (LightroomStep::ReadPhotos, 40, 100)); + forward(&p); + set(&p.read_done, 100); + set(&p.add_done, 25); + assert_eq!(p.step(), (LightroomStep::AddPhotos, 25, 100)); + forward(&p); + set(&p.add_done, 100); + forward(&p); + assert!(p.fraction() > 0.999); + // every photo already in the library: nothing to read + let known = LightroomProgress::default(); + known.photos.store(true, Ordering::Relaxed); + set(&known.add_total, 5); + set(&known.add_done, 5); + assert_eq!(known.step(), (LightroomStep::AddPhotos, 5, 5)); + } + #[test] fn cancellation_before_commit_leaves_owner_catalog_unchanged() { let mut session = crate::Session::new(); diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index a7101e61..b6716e83 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1815,6 +1815,8 @@ "Install the SAM 3 Model": "SAM-3-Modell installieren", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Diese Version kann das Modell noch nicht herunterladen. Zum Installieren von Hand lege model.safetensors, vocab.json und merges.txt aus Metas facebook/sam3 in den obigen Ordner: Objekt und Beschreiben funktionieren, sobald sie dort liegen.", "Open Log Folder": "Protokollordner öffnen", + "Reading photos…": "Fotos werden gelesen…", + "Adding photos…": "Fotos werden hinzugefügt…", "Back to this person": "Back to this person", "AI Denoise": "AI Denoise", "AI Denoise Model": "AI Denoise Model", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index d564559f..a0e0e755 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1749,6 +1749,8 @@ "cached picture": "imagen en caché", "cached pictures": "imágenes en caché", "commercial use allowed": "uso comercial permitido", + "Reading photos…": "Leyendo fotos…", + "Adding photos…": "Añadiendo fotos…", "denoiseModel.accept": "denoiseModel.accept", "in use": "en uso", "installed": "instalado", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 203211a6..1f6d4d14 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1659,6 +1659,8 @@ "Install the SAM 3 Model": "SAM 3モデルのインストール", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "このビルドではまだモデルをダウンロードできません。手動でインストールするには、Meta の facebook/sam3 の model.safetensors、vocab.json、merges.txt を上のフォルダに置いてください。置いた時点で「オブジェクト」と「説明で選択」が使えるようになります。", "Open Log Folder": "ログフォルダーを開く", + "Reading photos…": "写真を確認中…", + "Adding photos…": "写真を追加中…", "Back to this person": "Back to this person", "AI Denoise": "AI Denoise", "AI Denoise Model": "AI Denoise Model", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 900ea9e4..c843383c 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -950,6 +950,8 @@ "Open Library": "Abrir biblioteca", "Open Library…": "Abrir biblioteca…", "Open Log Folder": "Abrir pasta de logs", + "Reading photos…": "Lendo fotos…", + "Adding photos…": "Adicionando fotos…", "Open in": "Abrir em", "Open in Detail": "Abrir em Detalhe", "Open the place in OpenStreetMap": "Abrir o local no OpenStreetMap", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 6886f621..3aeb4109 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -785,6 +785,8 @@ "Open Library": "Открыть библиотеку", "Open Library…": "Открыть библиотеку…", "Open Log Folder": "Открыть папку журналов", + "Reading photos…": "Чтение фото…", + "Adding photos…": "Добавление фото…", "Open in": "Открыть в", "Open in Detail": "Открыть в режиме «Детализация»", "Open the place in OpenStreetMap": "Открыть место в OpenStreetMap", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 26173155..8d16fea2 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1659,6 +1659,8 @@ "Install the SAM 3 Model": "安装 SAM 3 模型", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚无法下载模型。如需手动安装,请将 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的文件夹:放入后即可使用“对象”和“描述”。", "Open Log Folder": "打开日志文件夹", + "Reading photos…": "正在读取照片…", + "Adding photos…": "正在添加照片…", "Back to this person": "返回此人", "AI Denoise": "AI 降噪", "AI Denoise Model": "AI 降噪模型", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index abeb97d4..42f28b07 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1659,6 +1659,8 @@ "Install the SAM 3 Model": "安裝 SAM 3 模型", "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "此版本尚無法下載模型。如需手動安裝,請將 Meta 的 facebook/sam3 中的 model.safetensors、vocab.json 和 merges.txt 放入上方的資料夾:放入後即可使用「物件」與「描述」。", "Open Log Folder": "開啟記錄資料夾", + "Reading photos…": "正在讀取照片…", + "Adding photos…": "正在新增照片…", "Back to this person": "Back to this person", "AI Denoise": "AI Denoise", "AI Denoise Model": "AI Denoise Model", diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 815e9857..ef6b4943 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -5,10 +5,11 @@ //! data only on the owner thread, then finishes the archive index on a worker. use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, mpsc}; use lightcraft_engine::activity::{Cancel, TaskGuard}; +use lightcraft_engine::lightroom_job::{LightroomProgress, LightroomStep}; use serde_json::{Value, json}; use crate::LightcraftApp; @@ -37,8 +38,7 @@ pub struct LightroomTask { kind: Kind, path: PathBuf, cancel: Arc, - total: Arc, - done: Arc, + progress: LightroomProgress, rx: mpsc::Receiver, phase: Phase, cancelled: bool, @@ -52,8 +52,7 @@ impl LightroomTask { kind: Kind, path: PathBuf, cancel: Arc, - total: Arc, - done: Arc, + progress: LightroomProgress, rx: mpsc::Receiver, ) -> Self { let label = match kind { @@ -61,7 +60,7 @@ impl LightroomTask { Kind::Import => "Importing Lightroom catalog", }; let guard = s.activity.start("lightroom", label, Cancel::Flag(cancel.clone())); - let task = Self { kind, path, cancel, total, done, rx, phase: Phase::Reading, cancelled: false, guard }; + let task = Self { kind, path, cancel, progress, rx, phase: Phase::Reading, cancelled: false, guard }; task.refresh(); task } @@ -73,16 +72,21 @@ impl LightroomTask { self.refresh(); } - /// Bring the activity row up to date: the count while reading, then what is being done. + /// Bring the activity row up to date: while reading, the step under way and its own count + /// (reading the photos takes nearly all of an import, so it is counted, not waited out); + /// then what is being done. fn refresh(&self) { - let detail = match (self.phase, self.kind) { - (Phase::Reading, Kind::Inspect) => "", - (Phase::Reading, Kind::Import) => crate::i18n::tr("Reading Lightroom catalog…"), - (Phase::Committing, _) => crate::i18n::tr("Adding Lightroom photos…"), - (Phase::Finalizing, _) => crate::i18n::tr("Saving Lightroom import index…"), + let (step, done, total) = self.progress.step(); + let detail = match (self.phase, self.kind, step) { + (Phase::Reading, Kind::Inspect, _) => "", + (Phase::Reading, Kind::Import, LightroomStep::Catalog) => crate::i18n::tr("Reading Lightroom catalog…"), + (Phase::Reading, Kind::Import, LightroomStep::ReadPhotos) => crate::i18n::tr("Reading photos…"), + (Phase::Reading, Kind::Import, LightroomStep::AddPhotos) => crate::i18n::tr("Adding photos…"), + (Phase::Committing, ..) => crate::i18n::tr("Adding Lightroom photos…"), + (Phase::Finalizing, ..) => crate::i18n::tr("Saving Lightroom import index…"), }; if self.phase == Phase::Reading { - self.guard.progress(self.done.load(Ordering::Relaxed) as u64, self.total.load(Ordering::Relaxed) as u64); + self.guard.progress(done as u64, total as u64); } else { self.guard.progress(0, 0); } @@ -100,13 +104,21 @@ impl LightroomTask { Phase::Committing => "committing", Phase::Finalizing => "finalizing", }; + let (step, done, total) = self.progress.step(); + let step = match step { + LightroomStep::Catalog => "catalog", + LightroomStep::ReadPhotos => "readPhotos", + LightroomStep::AddPhotos => "addPhotos", + }; json!({ "running": true, "kind": kind, "path": self.path, "phase": phase, - "done": self.done.load(Ordering::Relaxed), - "total": self.total.load(Ordering::Relaxed), + "step": step, + "done": done, + "total": total, + "fraction": self.progress.fraction(), "cancelled": self.cancelled || self.cancel.load(Ordering::Relaxed), }) } @@ -148,8 +160,7 @@ fn busy(app: &LightcraftApp) -> Result<(), String> { fn spawn_inspect( path: PathBuf, cancel: Arc, - total: Arc, - done: Arc, + progress: LightroomProgress, tx: mpsc::Sender, ctx: egui::Context, ) -> Result<(), String> { @@ -157,7 +168,7 @@ fn spawn_inspect( .name("lc-lightroom-inspect".into()) .spawn(move || { let result = lightcraft_engine::guard::catch("Lightroom inspect", || { - lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &total, &done) + lightcraft_engine::lightroom_catalog::read_with_progress(&path, &cancel, &progress.catalog_total, &progress.catalog_done) }) .and_then(|r| r.map(inspect_report)); let _ = tx.send(Message::Inspected(result)); @@ -278,12 +289,11 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result { busy(app)?; let cancel = Arc::new(AtomicBool::new(false)); - let total = Arc::new(AtomicUsize::new(0)); - let done = Arc::new(AtomicUsize::new(0)); + let progress = LightroomProgress::default(); let (tx, rx) = mpsc::channel(); - spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?; + spawn_inspect(path.clone(), cancel.clone(), progress.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask::new(&app.session, Kind::Inspect, path.clone(), cancel, total, done, rx)); + app.lightroom = Some(LightroomTask::new(&app.session, Kind::Inspect, path.clone(), cancel, progress, rx)); Ok(json!({"running": true, "kind": "inspect", "path": path})) } @@ -292,12 +302,11 @@ fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, c busy(app)?; let job = lightcraft_engine::lightroom_job::LightroomJob::new(&mut app.session, path.clone(), update_existing).map_err(|e| e.to_string())?; let cancel = Arc::new(AtomicBool::new(false)); - let total = job.total_atomic(); - let done = job.done_atomic(); + let progress = job.progress(); let (tx, rx) = mpsc::channel(); spawn_prepare(job, cancel.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask::new(&app.session, Kind::Import, path.clone(), cancel, total, done, rx)); + app.lightroom = Some(LightroomTask::new(&app.session, Kind::Import, path.clone(), cancel, progress, rx)); Ok(json!({"running": true, "kind": "import", "path": path})) } @@ -463,11 +472,11 @@ mod tests { use lightcraft_engine::lightroom_catalog::CatalogImport; /// A task as `start_import` makes it, fed by a channel the test holds instead of a worker. - fn fake_import(h: &crate::headless::Headless) -> (LightroomTask, mpsc::Sender, Arc, Arc, Arc) { + fn fake_import(h: &crate::headless::Headless) -> (LightroomTask, mpsc::Sender, Arc, LightroomProgress) { let (tx, rx) = mpsc::channel(); - let (cancel, total, done) = (Arc::new(AtomicBool::new(false)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); - let task = LightroomTask::new(&h.app.session, Kind::Import, "fixture.lrcat".into(), cancel.clone(), total.clone(), done.clone(), rx); - (task, tx, cancel, total, done) + let (cancel, progress) = (Arc::new(AtomicBool::new(false)), LightroomProgress::default()); + let task = LightroomTask::new(&h.app.session, Kind::Import, "fixture.lrcat".into(), cancel.clone(), progress.clone(), rx); + (task, tx, cancel, progress) } fn demo() -> crate::headless::Headless { @@ -481,10 +490,10 @@ mod tests { #[test] fn lightroom_row_is_not_cancellable_while_committing() { let mut h = demo(); - let (task, tx, cancel, total, done) = fake_import(&h); + let (task, tx, cancel, progress) = fake_import(&h); h.app.lightroom = Some(task); - total.store(10, Ordering::Relaxed); - done.store(3, Ordering::Relaxed); + progress.catalog_total.store(10, Ordering::Relaxed); + progress.catalog_done.store(3, Ordering::Relaxed); h.step(); let rows = h.app.session.activity.list(); assert_eq!(rows.len(), 1); @@ -505,7 +514,7 @@ mod tests { #[test] fn lightroom_cancel_from_the_stack_stops_the_read() { let mut h = demo(); - let (task, tx, cancel, _, _) = fake_import(&h); + let (task, tx, cancel, _) = fake_import(&h); h.app.lightroom = Some(task); h.step(); let id = h.app.session.activity.list()[0].id;