LR-BEHAV-BG-TASKS: Reload from Disk reads its files in the background

photo.reload decoded every targeted file on the UI thread, so a selection
of raws froze the window until all were read. The command is now split into
plan / probe / apply (engine `cmd::reload`); the app intercepts photo.reload
and probes the files on a worker thread with a cancellable activity row
(kind `reload`, counted progress), applying the result as one undo step.
`wait: true` keeps the old in-place behaviour for agents; CLI/MCP unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Matthew James Sivak
2026-10-15 15:35:11 +02:00
co-authored by Claude Opus 5.5
parent 0da998f24d
commit b9b80aee8a
13 changed files with 283 additions and 35 deletions
+9 -24
View File
@@ -313,30 +313,15 @@ pub(crate) fn reload_ops(
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
/// dimensions and content hash, cached sources dropped. The app runs the same steps
/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let ids = s.targets(p)?;
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
.iter()
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
Some(Source::File { path }) => Some((*id, path)),
_ => None,
})
.collect();
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let Ok(info) = info else { continue };
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
}
if !ops.is_empty() {
s.commit("Reload Changed Files", Op::Batch { ops })?;
}
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
use crate::cmd::reload as r;
let items = r::plan(s, p)?;
let probe = r::file_probe(s);
let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {});
let reloaded = r::apply(s, items, results)?;
Ok(r::to_json(&reloaded))
}
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
@@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
"Reload from Disk",
["Photo"],
None,
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}",
has_selection,
reload
),
+1
View File
@@ -38,6 +38,7 @@ pub mod previews;
mod print;
mod query;
pub mod recheck;
pub mod reload;
mod slideshow;
mod sync;
mod xmp;
+93
View File
@@ -0,0 +1,93 @@
//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a
//! raw re-decode takes a second or more per file, and a selection of them froze the window while
//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads
//! their files without the session (cancellable, with progress), and [`apply`] commits what came
//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once.
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use lightcraft_catalog::{Op, PhotoId, Source};
use serde_json::{Value, json};
use crate::media::{FileProbe, ProbeInfo};
use crate::{Result, Session};
/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile
/// is skipped by [`apply`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Item {
pub id: PhotoId,
pub path: String,
}
/// The photos `p` targets (`ids`, else the selection) that have a file. No file-system calls.
pub fn plan(s: &Session, p: &Value) -> Result<Vec<Item>> {
Ok(s.targets(p)?
.into_iter()
.filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) {
Some(Source::File { path }) => Some(Item { id, path: path.clone() }),
_ => None,
})
.collect())
}
/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file
/// access, where a reload reads nothing but the file name, as the command does).
pub fn file_probe(s: &Session) -> Option<FileProbe> {
s.media.file_probe.clone()
}
/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session.
/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further
/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]).
pub fn probe(
probe: Option<&FileProbe>,
items: &[Item],
cancel: &AtomicBool,
progress: &dyn Fn(usize),
) -> Vec<std::result::Result<ProbeInfo, String>> {
let paths: Vec<String> = items.iter().map(|i| i.path.clone()).collect();
let done = AtomicUsize::new(0);
#[cfg(not(target_arch = "wasm32"))]
let r = std::thread::scope(|sc| {
let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel));
while !h.is_finished() {
progress(done.load(Ordering::Relaxed));
std::thread::sleep(std::time::Duration::from_millis(50));
}
// a probe that panicked (already logged): nothing is known about any file
h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect())
});
#[cfg(target_arch = "wasm32")]
let r = crate::import::probe_all(probe, &paths, &done, cancel);
progress(done.load(Ordering::Relaxed));
r
}
/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual
/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed
/// or relinked since it was listed is skipped. Returns the photos that changed.
pub fn apply(s: &mut Session, items: Vec<Item>, results: Vec<std::result::Result<ProbeInfo, String>>) -> Result<Vec<PhotoId>> {
let mut ops = Vec::new();
let mut reloaded = Vec::new();
for (item, info) in items.into_iter().zip(results) {
let Ok(info) = info else { continue };
let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path));
if !current {
continue;
}
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded);
}
for id in &reloaded {
s.media.forget(*id);
}
if !ops.is_empty() {
s.commit("Reload Changed Files", Op::Batch { ops })?;
}
Ok(reloaded)
}
/// `{reloaded: [ids]}`.
pub fn to_json(reloaded: &[PhotoId]) -> Value {
json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()})
}
+1 -1
View File
@@ -494,7 +494,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V
/// 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(
pub(crate) fn probe_all(
probe: Option<&crate::media::FileProbe>,
paths: &[String],
done: &std::sync::atomic::AtomicUsize,
+1
View File
@@ -33,6 +33,7 @@ pub mod pick;
pub mod print_view;
pub mod recheck;
pub mod region;
pub mod reload;
pub mod render;
pub mod shortcuts;
pub mod sidebar;
+4
View File
@@ -417,6 +417,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
let ctx = app.tasks.repaint.clone().unwrap_or_default();
return Some(crate::lightroom_import::command(app, id, p, &ctx));
}
if id == "photo.reload" {
// the files are read in the background (a raw's decode takes a second or more)
return crate::reload::command(app, p);
}
if let Some(language) = language_from_command(id) {
app.ui.language = language;
// Immediately, not on the next frame: the reply and anything else run this frame
+8 -5
View File
@@ -1799,13 +1799,16 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) {
}
/// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can
/// decode, whose drive was offline when the library opened), and say what came of it.
/// decode, whose drive was offline when the library opened) in the background; a toast says what
/// came of it once it's done (`crate::reload`).
pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) {
let r = app.run("photo.reload", json!({"ids": [id.0]}));
let decoded = app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none());
match r {
match app.run("photo.reload", json!({"ids": [id.0]})) {
Err(e) => app.toast_error(ctx, e),
Ok(_) if decoded => app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")),
Ok(r) if r["background"] == true => {}
// nothing to read in the background (a photo without a file): answered in place
Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => {
app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data"))
}
Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")),
}
}
+4 -1
View File
@@ -1162,7 +1162,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
}
if ui.button(crate::i18n::tr("Reload from Disk")).clicked() {
let _ = app.run("photo.reload", json!({}));
// the files are read in the background (activity row `reload`)
if let Err(e) = app.run("photo.reload", json!({})) {
app.toast_error(ui.ctx(), e);
}
}
if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() {
let _ = app.run("photo.virtualCopy", json!({}));
+67
View File
@@ -0,0 +1,67 @@
//! Reload from Disk in the app: the files are read on a worker thread (a raw's decode takes a
//! second or more, and a selection of them froze the window), with a row in the activity stack
//! that counts the files and can stop. What came of it is applied back here, as one undo step,
//! unless another library was opened meanwhile. Agents get the same with `wait: true`, which runs
//! the engine's command in place.
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use lightcraft_engine::cmd::reload;
use serde_json::{Value, json};
use crate::LightcraftApp;
/// The activity stack's label while the files are read (the menu item's name, already translated).
pub const LABEL: &str = "Reload from Disk";
/// `photo.reload` from the app: start reading the targeted files in the background →
/// `{background: true, count}`. `None` hands the command to the engine: with `wait: true`, or when
/// no targeted photo has a file (it then answers at once, refusing an empty selection as usual).
pub fn command(app: &mut LightcraftApp, p: &Value) -> Option<Result<Value, String>> {
if p.get("wait").and_then(Value::as_bool) == Some(true) {
return None;
}
let items = match reload::plan(&app.session, p) {
Ok(items) => items,
Err(e) => return Some(Err(format!("photo.reload: {e}"))),
};
if items.is_empty() {
return None;
}
let count = items.len();
// the photos shown from their embedded preview, to say afterwards whether they decode now
let preview_only: Vec<_> =
items.iter().filter(|i| app.session.catalog.photo(i.id).is_some_and(|ph| ph.preview_only.is_some())).map(|i| i.id).collect();
let library = app.session.library_generation();
let probe = reload::file_probe(&app.session);
let cancel = Arc::new(AtomicBool::new(false));
let flag = cancel.clone();
let work = move |task: lightcraft_engine::activity::TaskHandle| {
let total = items.len() as u64;
task.progress(0, total);
let results = reload::probe(probe.as_ref(), &items, &flag, &|done| task.progress(done as u64, total));
(items, results)
};
let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results)| {
if app.session.library_generation() != library {
return;
}
if let Err(e) = reload::apply(&mut app.session, items, results) {
return app.toast_error(ctx, e.to_string());
}
let cleared = preview_only.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|ph| ph.preview_only.is_none())).count();
let message = match (count, preview_only.len(), cleared) {
(1, 1, 1) => crate::i18n::tr("This raw is now developed from its raw data").to_string(),
(1, 1, _) => crate::i18n::tr("This raw variant still can't be decoded").to_string(),
(_, _, 0) => return,
(_, _, 1) => crate::i18n::tr("1 raw can now be developed from its raw data").to_string(),
(_, _, n) => crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n),
};
app.toast(ctx, message);
};
if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, "reload", cancel, work, done) {
return Some(Err(e));
}
Some(Ok(json!({"background": true, "count": count})))
}
+28 -1
View File
@@ -50,6 +50,34 @@ pub fn spawn<T: Send + 'static>(
kind: Option<&'static str>,
work: impl FnOnce() -> T + Send + 'static,
done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,
) -> Result<(), String> {
let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No));
start(app, label, guard, work, done)
}
/// [`spawn`] for a task with progress that can stop: its row (`kind`, `label`) adopts `cancel`,
/// the flag `work` checks, so the row's ✕ and `activity.cancel` stop it; `work` reports progress
/// through the handle it is given.
pub fn spawn_tracked<T: Send + 'static>(
app: &mut LightcraftApp,
label: &str,
kind: &'static str,
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
work: impl FnOnce(lightcraft_engine::activity::TaskHandle) -> T + Send + 'static,
done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,
) -> Result<(), String> {
let guard = app.session.activity.start(kind, label, lightcraft_engine::activity::Cancel::Flag(cancel));
let handle = guard.handle();
start(app, label, Some(guard), move || work(handle), done)
}
/// Run `work` on a worker thread and list it in `app.tasks` with its activity row (if any).
fn start<T: Send + 'static>(
app: &mut LightcraftApp,
label: &str,
guard: Option<lightcraft_engine::activity::TaskGuard>,
work: impl FnOnce() -> T + Send + 'static,
done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,
) -> Result<(), String> {
let (tx, rx) = channel::<Finish>();
let repaint = app.tasks.repaint.clone();
@@ -64,7 +92,6 @@ pub fn spawn<T: Send + 'static>(
std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?;
#[cfg(target_arch = "wasm32")]
job();
let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No));
app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard });
Ok(())
}
+63 -1
View File
@@ -78,13 +78,75 @@ fn recheck_result_is_dropped_after_switching_libraries() {
assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched");
}
/// The notice's Reload from Disk re-reads the file and says what came of it.
/// The notice's Reload from Disk re-reads the file in the background (a row in the activity stack,
/// the window not waiting for the decode) and says what came of it.
#[test]
fn notice_reload_button_rereads_the_file() {
let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-reload-{}", std::process::id())));
let (mut app, id) = app_with_stale_photo(&dir.0);
let ctx = egui::Context::default();
crate::panels::edit::reload_preview_only(&mut app, &ctx, id);
let rows = app.session.activity.list();
let row = rows.iter().find(|r| r.kind == "reload").unwrap_or_else(|| panic!("no reload row: {rows:?}"));
assert_eq!(row.label, crate::reload::LABEL);
assert!(row.cancellable);
assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));
assert!(app.session.activity.list().iter().all(|r| r.kind != "reload"), "the row goes with the task");
assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None);
assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast);
// one undo step
assert_eq!(app.session.undo.last().map(|u| u.label.as_str()), Some("Reload Changed Files"));
}
/// Reload from Disk on a selection returns at once and reads the files on a worker thread; `wait`
/// runs it in place; a cancelled reload changes nothing it didn't read.
#[test]
fn reload_of_a_selection_runs_in_the_background() {
let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-sel-{}", std::process::id())));
let (mut app, id) = app_with_stale_photo(&dir.0);
let ctx = egui::Context::default();
app.session.selection = lightcraft_engine::Selection { ids: vec![id], active: Some(id) };
let r = app.run("photo.reload", json!({})).unwrap();
assert_eq!(r, json!({"background": true, "count": 1}));
assert!(app.tasks.is_running(crate::reload::LABEL));
assert!(app.session.activity.list().iter().any(|r| r.kind == "reload"));
assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));
assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None);
// agents can wait for the result: the engine's reply, nothing left running
let r = app.run("photo.reload", json!({"ids": [id.0], "wait": true})).unwrap();
assert!(r.get("reloaded").is_some(), "{r}");
assert!(app.tasks.is_empty());
// cancelled before any file is read: nothing changes
let p = app.session.catalog.photo(id).unwrap().clone();
app.session
.catalog
.apply(lightcraft_catalog::Op::SetContent {
id,
width: p.width,
height: p.height,
file_size: p.file_size,
content_hash: p.content_hash.clone(),
preview_only: Some("Cr3 files are not decoded yet".into()),
preview_checked: "0.5.0".into(),
})
.unwrap();
let items = lightcraft_engine::cmd::reload::plan(&app.session, &json!({"ids": [id.0]})).unwrap();
let probe = lightcraft_engine::cmd::reload::file_probe(&app.session);
let cancelled = std::sync::atomic::AtomicBool::new(true);
let results = lightcraft_engine::cmd::reload::probe(probe.as_ref(), &items, &cancelled, &|_| {});
assert!(lightcraft_engine::cmd::reload::apply(&mut app.session, items, results).unwrap().is_empty());
assert!(app.session.catalog.photo(id).unwrap().preview_only.is_some());
}
/// With no photo selected the command is refused at once, as before (no task, no row).
#[test]
fn reload_with_nothing_selected_starts_nothing() {
let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-none-{}", std::process::id())));
let (mut app, _) = app_with_stale_photo(&dir.0);
app.session.selection = lightcraft_engine::Selection::default();
let _ = app.run("photo.reload", json!({}));
assert!(app.tasks.is_empty());
assert!(app.session.activity.list().iter().all(|r| r.kind != "reload"));
}
+1
View File
@@ -60,6 +60,7 @@ guard.detail(&file_name);
| Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish |
| Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state |
| Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) |
| Reload from Disk | `crates/ui-egui/src/reload.rs`, `crates/engine/src/cmd/reload.rs` | a command split into plan / probe / apply so the app reads the files on a worker thread (`tasks::spawn_tracked`: progress and `Cancel::Flag`); `wait: true` runs it in place |
| Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps |
## Testing
+3 -2
View File
@@ -1,6 +1,6 @@
# Lightroom parity checklist
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (integration batch 36: slideshow module, print-ready PDF fixes, local adjustment presets, Layout Image loupe overlay, Delete in an album removes from it, new languages Czech, Polish, Italian-ready catalogs; many rows' notes updated by their PRs) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
@@ -461,7 +461,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe |
| LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |
| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) |
| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) |
| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline |
| LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) |
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |
@@ -797,6 +797,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; status unchanged (✅) |
| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) |
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) |
| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) |