mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
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:
co-authored by
Claude Opus 5.5
parent
0da998f24d
commit
b9b80aee8a
@@ -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
|
||||
),
|
||||
|
||||
@@ -38,6 +38,7 @@ pub mod previews;
|
||||
mod print;
|
||||
mod query;
|
||||
pub mod recheck;
|
||||
pub mod reload;
|
||||
mod slideshow;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
|
||||
@@ -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<_>>()})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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})))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (✅) |
|
||||
|
||||
Reference in New Issue
Block a user