diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 6fe665896..d5fdafe51 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -47,7 +47,7 @@ pub use files::{EXTENSIONS, LaunchPath, accept_list, expand, expand_within, is_s pub(crate) use files::{expand_noting, probe_all, probe_paths, probe_with}; #[cfg(test)] use job::PreparedItem; -pub use job::{ImportJob, Prepared, import, import_with}; +pub use job::{ImportJob, Prepared, candidate_thumb_key, import, import_with}; pub(crate) use scan::scan_files; pub use scan::{ScanInput, ScanOutput, ScanProgress, scan, scan_with}; #[cfg(test)] diff --git a/crates/engine/src/import/job.rs b/crates/engine/src/import/job.rs index 1730518d5..a178035da 100644 --- a/crates/engine/src/import/job.rs +++ b/crates/engine/src/import/job.rs @@ -431,6 +431,11 @@ impl ImportJob { } } +/// Identity of a candidate preview at the bounded requested edge, without constructing a job. +pub fn candidate_thumb_key(c: &ImportCandidate, edge: usize) -> u64 { + lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge.clamp(64, 1024) as u64).finish().0 as u64 +} + impl Session { /// A thumbnail for an import candidate (not in the catalog): a raw's embedded preview, else a /// small render of the file. `slot` makes the job's photo id unique (`u64::MAX - slot`). @@ -447,10 +452,13 @@ impl Session { p.preview_only = info.preview_only.clone(); } p.develop = std::sync::Arc::new(p.import_defaults()); - let edge = edge.clamp(64, crate::media::SourceLevel::Thumb.max_edge()); - let level = crate::media::SourceLevel::Thumb; + let edge = edge.clamp(64, 1024); + let level = crate::media::SourceLevel::for_size(edge); let source = self.media.origin_ref(&p.source, level.max_edge()); - let key = lightcraft_preview::Hasher128::new().str(&c.path).u64(c.file_size).u64(edge as u64).finish().0 as u64; + let key = candidate_thumb_key(c, edge); + // A small original already supplies all its detail; do not enlarge it to the bucket. + let native_edge = c.width.max(c.height) as usize; + let render_edge = if native_edge > 0 { edge.min(native_edge) } else { edge }; let small = crate::media::RenderJob { request_id: 0, cache_generation: self.media.rendered.generation(), @@ -461,7 +469,7 @@ impl Session { origin: p.source.clone(), info: crate::media::source_info(&p), settings: p.develop.clone(), - request: lightcraft_pipeline::RenderRequest::fit(edge, edge), + request: lightcraft_pipeline::RenderRequest::fit(render_edge, render_edge), key, cache: None, stages: None, @@ -475,7 +483,16 @@ impl Session { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), _ => None, }; - Some(crate::media::QuickJob { request_id: 0, photo: id, key, cached: Vec::new(), embedded, small: Some(Box::new(small)) }) + let minimum_embedded_edge = (c.preview_only.is_none() && p.preview_only.is_none()).then_some(render_edge); + Some(crate::media::QuickJob { + request_id: 0, + photo: id, + key, + cached: Vec::new(), + embedded, + minimum_embedded_edge, + small: Some(Box::new(small)), + }) } /// Use the system clock for import/edit times (native hosts). @@ -484,3 +501,6 @@ impl Session { self } } + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/import/job/tests.rs b/crates/engine/src/import/job/tests.rs new file mode 100644 index 000000000..82490915b --- /dev/null +++ b/crates/engine/src/import/job/tests.rs @@ -0,0 +1,92 @@ +//! Import previews must cover the tile when possible, retaining camera JPEGs as a safe fallback. + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use crate::Session; +use crate::import::ImportCandidate; +use crate::media::QuickSource; + +/// Synthetic raw, bounded decoder and camera JPEG, with reads counted independently of pixels. +fn fixture(preview_edge: Option, renderable: bool, preview_only: bool) -> (Session, ImportCandidate, Arc) { + let mut session = Session::new(); + let reads = Arc::new(AtomicUsize::new(0)); + let count = reads.clone(); + session.media.file_loader = Some(Arc::new(move |_, edge| { + count.fetch_add(1, Ordering::SeqCst); + if !renderable { + return Err("synthetic raw cannot be rendered".into()); + } + let width = edge.min(1024); + let height = width * 3 / 4; + Ok((lightcraft_raster::Rgb32f { width, height, data: vec![[0.1, 0.2, 0.3]; width * height] }, Default::default())) + })); + session.media.preview_loader = Some(Arc::new(move |_, requested| { + let width = preview_edge?.min(requested); + Some(lightcraft_raster::Rgba8 { width, height: width * 3 / 4, data: vec![[30, 60, 90, 255]; width * (width * 3 / 4)] }) + })); + let candidate = ImportCandidate { + path: "synthetic-import.nef".into(), + name: "synthetic-import.nef".into(), + format: "NEF".into(), + kind: lightcraft_catalog::MediaKind::Raw, + width: 1024, + height: 768, + preview_only: preview_only.then(|| "unsupported synthetic raw variant".into()), + ..Default::default() + }; + (session, candidate, reads) +} + +#[test] +/// A renderable raw fills the requested tile when its embedded JPEG is too small. +fn import_preview_renders_when_the_camera_jpeg_cannot_cover_the_tile() { + let (mut session, candidate, reads) = fixture(Some(64), true, false); + let mut job = session.candidate_thumb_job(&candidate, 768, 0).unwrap(); + job.request_id = 42; + let key = job.key; + let result = job.run(); + assert_eq!(result.quick, Some(QuickSource::Small)); + let image = result.rendered.unwrap().image; + assert_eq!((image.width, image.height), (768, 576)); + assert_eq!(reads.load(Ordering::SeqCst), 1); + assert_eq!((result.request_id, result.key), (42, key)); +} + +#[test] +/// A sufficient camera JPEG avoids decoding the raw, retaining the fast path. +fn import_preview_keeps_a_camera_jpeg_that_already_covers_the_tile() { + let (mut session, candidate, reads) = fixture(Some(1024), true, false); + let result = session.candidate_thumb_job(&candidate, 768, 0).unwrap().run(); + assert_eq!(result.quick, Some(QuickSource::Embedded)); + assert_eq!(result.rendered.unwrap().image.width, 768); + assert_eq!(reads.load(Ordering::SeqCst), 0); +} + +#[test] +/// Preview-only raws never waste a render attempt behind their available camera JPEG. +fn import_preview_only_raw_uses_the_available_camera_jpeg_without_a_render() { + let (mut session, candidate, reads) = fixture(Some(64), false, true); + let result = session.candidate_thumb_job(&candidate, 768, 0).unwrap().run(); + assert_eq!(result.quick, Some(QuickSource::Embedded)); + assert_eq!(result.rendered.unwrap().image.width, 64); + assert_eq!(reads.load(Ordering::SeqCst), 0); +} + +#[test] +/// A failed or absent render never discards the usable, if undersized, camera JPEG. +fn import_preview_retains_the_camera_jpeg_if_the_render_fails_or_is_absent() { + for no_render in [false, true] { + let (mut session, candidate, reads) = fixture(Some(64), false, false); + let mut job = session.candidate_thumb_job(&candidate, 768, 0).unwrap(); + if no_render { + job.small = None; + } + let result = job.run(); + assert_eq!(result.quick, Some(QuickSource::Embedded)); + assert_eq!(result.rendered.unwrap().image.width, 64); + assert_eq!(reads.load(Ordering::SeqCst), usize::from(!no_render)); + } +} diff --git a/crates/engine/src/media/quick.rs b/crates/engine/src/media/quick.rs index ca399b561..21edb2bed 100644 --- a/crates/engine/src/media/quick.rs +++ b/crates/engine/src/media/quick.rs @@ -25,6 +25,10 @@ pub struct QuickJob { pub cached: Vec<(Arc, Hash128)>, /// Embedded preview of an unedited raw: (path, loader, max edge). pub embedded: Option<(String, PreviewLoader, usize)>, + /// Import tiles need this many pixels when a render can provide them. `None` accepts any + /// camera JPEG immediately (ordinary transient stand-ins and preview-only imports). + /// An undersized JPEG remains the fallback if the render fails or is unavailable. + pub minimum_embedded_edge: Option, /// Last resort: run this thumbnail job. pub small: Option>, } @@ -72,14 +76,23 @@ impl QuickJob { return done(rendered(Arc::unwrap_or_clone(img)), Some(QuickSource::Cached)); } } + let mut embedded_fallback = None; if let Some((path, loader, edge)) = &self.embedded && let Some(image) = loader(path, *edge) { - return done(rendered(image), Some(QuickSource::Embedded)); + if self.minimum_embedded_edge.is_none_or(|minimum| image.width.max(image.height) >= minimum) { + return done(rendered(image), Some(QuickSource::Embedded)); + } + embedded_fallback = Some(image); } if let Some(job) = self.small { let r = job.run(); - return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r }; + if r.rendered.is_ok() || embedded_fallback.is_none() { + return RenderResult { request_id, key, quick: Some(QuickSource::Small), ..r }; + } + } + if let Some(image) = embedded_fallback { + return done(rendered(image), Some(QuickSource::Embedded)); } done(Err("no quick preview".into()), None) } diff --git a/crates/engine/src/media/session.rs b/crates/engine/src/media/session.rs index b222739fe..ea69c4907 100644 --- a/crates/engine/src/media/session.rs +++ b/crates/engine/src/media/session.rs @@ -342,7 +342,7 @@ impl crate::Session { let h = Hasher128::new().str(&content_key(&p)).str("quick").u64(p.develop.hash64()).u64(apply_crop as u64).finish(); let key = h.0 as u64; let embedded = self.embedded_of(&p).map(|(path, l)| (path, l, max_edge.clamp(1, SourceLevel::Preview.max_edge()))); - Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, small: apply_crop.then(|| Box::new(small)) }) + Some(QuickJob { request_id: 0, photo: id, key, cached, embedded, minimum_embedded_edge: None, small: apply_crop.then(|| Box::new(small)) }) } /// A first grid thumbnail for an unedited raw: the cached render for `job` (the real @@ -361,6 +361,7 @@ impl crate::Session { key: job.key, cached: job.cache.clone().into_iter().collect(), embedded: Some((path, l, edge)), + minimum_embedded_edge: None, small: Some(Box::new(job.clone())), }) } diff --git a/crates/engine/src/media/thumbnail_hash_tests.rs b/crates/engine/src/media/thumbnail_hash_tests.rs index fdbd20f80..3c23e9db9 100644 --- a/crates/engine/src/media/thumbnail_hash_tests.rs +++ b/crates/engine/src/media/thumbnail_hash_tests.rs @@ -107,6 +107,7 @@ fn a_render_that_panics_fails_instead_of_never_answering() { key: 9, cached: Vec::new(), embedded: Some(("x.arw".into(), Arc::new(|_: &str, _: usize| -> Option { panic!("preview reader bug") }), 256)), + minimum_embedded_edge: None, small: None, }; let r = quick.run(); diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 52ca94a52..fefc418ee 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -2408,5 +2408,6 @@ "Import dropped files without review": "Importovat přetažené soubory bez kontroly", "An import is running": "Import už běží", "An import is running. Drop photos when it has finished": "Import už běží. Fotky přetáhněte, až skončí", - "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky" + "Finish Lightroom catalog import before adding photos": "Dokončete import katalogu Lightroom, než přidáte fotky", + "Import Thumbnail Size": "Velikost miniatur při importu" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index e2205db78..cb9dfba52 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -3440,5 +3440,6 @@ "Import dropped files without review": "Abgelegte Dateien ohne Prüfung importieren", "An import is running": "Ein Import läuft", "An import is running. Drop photos when it has finished": "Ein Import läuft. Lege die Fotos ab, wenn er fertig ist", - "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst" + "Finish Lightroom catalog import before adding photos": "Schließe den Import des Lightroom-Katalogs ab, bevor du Fotos hinzufügst", + "Import Thumbnail Size": "Vorschaubildgröße beim Import" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 888af077f..892f33acc 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -2358,5 +2358,7 @@ "Import dropped files without review": "Importar los archivos soltados sin revisión", "An import is running": "Hay una importación en curso", "An import is running. Drop photos when it has finished": "Hay una importación en curso. Suelta las fotos cuando termine", - "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos" + "Finish Lightroom catalog import before adding photos": "Termina la importación del catálogo de Lightroom antes de añadir fotos", + "Thumbnail size": "Tamaño de las miniaturas", + "Import Thumbnail Size": "Tamaño de las miniaturas de importación" } diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json index e0a8c622a..87fbf8e30 100644 --- a/crates/ui-egui/locales/fi.json +++ b/crates/ui-egui/locales/fi.json @@ -2327,5 +2327,6 @@ "Drop to open the face model": "Pudota avataksesi kasvomallin", "Dropped Files": "Pudotetut tiedostot", "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä", - "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta" + "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta", + "Import Thumbnail Size": "Tuonnin pienoiskuvien koko" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 3278d6ccd..a37a9356a 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -2390,5 +2390,7 @@ "Checking recent libraries": "Vérification des bibliothèques récentes", "The photos are imported once a library is open": "Les photos seront importées dès qu'une bibliothèque sera ouverte", "Open a library first, then drop the presets again": "Ouvrez d'abord une bibliothèque, puis déposez à nouveau les préréglages", - "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :" + "This library couldn't be checked:": "Impossible de vérifier cette bibliothèque :", + "Thumbnail size": "Taille des vignettes", + "Import Thumbnail Size": "Taille des vignettes d’importation" } diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json index 0d6da9b8f..e9a73d62e 100644 --- a/crates/ui-egui/locales/id.json +++ b/crates/ui-egui/locales/id.json @@ -2339,5 +2339,7 @@ "Drop to open the face model": "Jatuhkan untuk membuka model wajah", "Dropped Files": "File yang dijatuhkan", "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto", - "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan" + "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan", + "Thumbnail size": "Ukuran gambar mini", + "Import Thumbnail Size": "Ukuran Gambar Mini Impor" } diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index e159fd47d..41c1a21e1 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -2361,5 +2361,7 @@ "Import dropped files without review": "Importa i file rilasciati senza revisione", "An import is running": "Un'importazione è in corso", "An import is running. Drop photos when it has finished": "Un'importazione è in corso. Trascina le foto quando è finita", - "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto" + "Finish Lightroom catalog import before adding photos": "Termina l'importazione del catalogo Lightroom prima di aggiungere foto", + "Thumbnail size": "Dimensione delle miniature", + "Import Thumbnail Size": "Dimensione delle miniature di importazione" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index e1834555b..6c977a022 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -2356,5 +2356,7 @@ "Import dropped files without review": "ドロップしたファイルを確認せずに読み込む", "An import is running": "読み込み中です", "An import is running. Drop photos when it has finished": "読み込み中です。完了してから写真をドロップしてください", - "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください" + "Finish Lightroom catalog import before adding photos": "写真を追加する前に Lightroom カタログの読み込みを完了してください", + "Thumbnail size": "サムネイルのサイズ", + "Import Thumbnail Size": "読み込みサムネイルのサイズ" } diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json index 32c407700..d6518d4f4 100644 --- a/crates/ui-egui/locales/ka.json +++ b/crates/ui-egui/locales/ka.json @@ -2734,5 +2734,8 @@ "Import dropped files without review": "ჩაგდებული ფაილების იმპორტი გადახედვის გარეშე", "An import is running": "იმპორტი მიმდინარეობს", "An import is running. Drop photos when it has finished": "იმპორტი მიმდინარეობს. ფოტოები ჩააგდეთ, როცა დასრულდება", - "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი" + "Finish Lightroom catalog import before adding photos": "ფოტოების დამატებამდე დაასრულეთ Lightroom-ის კატალოგის იმპორტი", + "Thumbnail size": "მინიატურების ზომა", + "Import Thumbnail Size": "იმპორტის მინიატურების ზომა", + "Click to type a value, drag to change it": "დააწკაპუნეთ მნიშვნელობის შესაყვანად, გადაათრიეთ მის შესაცვლელად" } diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index adac22e62..1d4b9d0f3 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -2283,5 +2283,7 @@ "Import dropped files without review": "Importuj upuszczone pliki bez przeglądu", "An import is running": "Import jest w toku", "An import is running. Drop photos when it has finished": "Import jest w toku. Upuść zdjęcia, gdy się zakończy", - "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia" + "Finish Lightroom catalog import before adding photos": "Zakończ import katalogu Lightrooma, zanim dodasz zdjęcia", + "Thumbnail size": "Rozmiar miniatur", + "Import Thumbnail Size": "Rozmiar miniatur importu" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index e84fb7615..936623ea0 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -2202,5 +2202,7 @@ "Import dropped files without review": "Importar arquivos soltos sem revisão", "An import is running": "Uma importação está em andamento", "An import is running. Drop photos when it has finished": "Uma importação está em andamento. Solte as fotos quando ela terminar", - "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos" + "Finish Lightroom catalog import before adding photos": "Conclua a importação do catálogo do Lightroom antes de adicionar fotos", + "Thumbnail size": "Tamanho das miniaturas", + "Import Thumbnail Size": "Tamanho das miniaturas de importação" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index f740aaed9..ee6196868 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2566,5 +2566,7 @@ "An import is running": "Импорт уже выполняется", "An import is running. Drop photos when it has finished": "Импорт уже выполняется. Перетащите фото, когда он закончится", "Finish Lightroom catalog import before adding photos": "Завершите импорт каталога Lightroom, прежде чем добавлять фото", - "★ Favorites": "★ Избранное" + "★ Favorites": "★ Избранное", + "Thumbnail size": "Размер миниатюр", + "Import Thumbnail Size": "Размер миниатюр при импорте" } diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json index 66e69b7ba..c9cc169b0 100644 --- a/crates/ui-egui/locales/sv.json +++ b/crates/ui-egui/locales/sv.json @@ -2429,5 +2429,7 @@ "Drop to open the face model": "Släpp för att öppna ansiktsmodellen", "Dropped Files": "Släppta filer", "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton", - "Import dropped files without review": "Importera släppta filer utan granskning" + "Import dropped files without review": "Importera släppta filer utan granskning", + "Import Thumbnail Size": "Miniatyrstorlek vid import", + "Click to type a value, drag to change it": "Klicka för att ange ett värde, dra för att ändra det" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index ee694ed4f..0d1acabfb 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2354,5 +2354,7 @@ "Import dropped files without review": "Імпортувати перетягнуті файли без перегляду", "An import is running": "Імпорт уже виконується", "An import is running. Drop photos when it has finished": "Імпорт уже виконується. Перетягніть фото, коли він завершиться", - "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото" + "Finish Lightroom catalog import before adding photos": "Завершіть імпорт каталогу Lightroom, перш ніж додавати фото", + "Thumbnail size": "Розмір мініатюр", + "Import Thumbnail Size": "Розмір мініатюр імпорту" } diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json index e4fb8feb8..d52d41217 100644 --- a/crates/ui-egui/locales/vi.json +++ b/crates/ui-egui/locales/vi.json @@ -2235,5 +2235,8 @@ "Drop to open the face model": "Thả để mở mô hình khuôn mặt", "Dropped Files": "Tệp đã thả", "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh", - "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại" + "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại", + "Thumbnail size": "Kích thước ảnh thu nhỏ", + "Import Thumbnail Size": "Kích thước ảnh thu nhỏ khi nhập", + "Click to type a value, drag to change it": "Nhấp để nhập giá trị, kéo để thay đổi" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 270af8a62..48339491c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2200,5 +2200,7 @@ "Import dropped files without review": "不经检查直接导入拖入的文件", "An import is running": "正在导入", "An import is running. Drop photos when it has finished": "正在导入。导入完成后再拖放照片", - "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入" + "Finish Lightroom catalog import before adding photos": "添加照片前,请先完成 Lightroom 目录的导入", + "Thumbnail size": "缩略图大小", + "Import Thumbnail Size": "导入缩略图大小" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 0408e23ec..26ad944b5 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -2157,5 +2157,7 @@ "Import dropped files without review": "不經檢查直接匯入拖放的檔案", "An import is running": "正在匯入", "An import is running. Drop photos when it has finished": "正在匯入。匯入完成後再拖放照片", - "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入" + "Finish Lightroom catalog import before adding photos": "新增照片前,請先完成 Lightroom 目錄的匯入", + "Thumbnail size": "縮圖大小", + "Import Thumbnail Size": "匯入縮圖大小" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index caacbc590..c32434569 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -434,6 +434,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest None => err("no dialog open"), }, "ui.dialog.cancel" => { + if matches!(app.ui.dialog, Some(crate::state::Dialog::Import { .. })) { + app.renderer.forget_imports(); + } app.ui.dialog = None; ok(Value::Null) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 646288220..aaafcb355 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -225,6 +225,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { let frame = ctx.cumulative_frame_nr(); ctx.data_mut(|d| d.insert_temp(egui::Id::unique(BACKDROP_SHOWN), frame)); } + if close && matches!(dlg, Dialog::Import { .. }) { + app.renderer.forget_imports(); + } app.ui.dialog = if close { None } else { Some(dlg) }; } diff --git a/crates/ui-egui/src/dialogs/import.rs b/crates/ui-egui/src/dialogs/import.rs index 8425c3ec9..e43c54280 100644 --- a/crates/ui-egui/src/dialogs/import.rs +++ b/crates/ui-egui/src/dialogs/import.rs @@ -8,7 +8,7 @@ //! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is //! one undo step. -use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Vec2, pos2, vec2}; use lightcraft_engine::import::{ ImportCandidate, ImportUprightMode, ImportWarning, ImportWarningCode, ScanInput, ScanOutput, ScanProgress, scan_with, }; @@ -29,9 +29,11 @@ mod task; #[cfg(test)] pub(crate) use body::grid_height; #[cfg(test)] +pub(crate) use body::thumb_edge; +#[cfg(test)] use body::{DEFAULT_BELOW_GRID, badge_rect, dup_counts}; pub use body::{DEFAULT_FOLDER_TEMPLATE, body, example_destination, source_summary}; -pub(crate) use body::{DIALOG_SIZE, note_dialog_bottom}; +pub(crate) use body::{DIALOG_SIZE, THUMB_SIZE_DEFAULT, note_dialog_bottom, thumb_size}; #[cfg(not(target_arch = "wasm32"))] pub(crate) use scan::cards_seen; pub use scan::{ScanTask, browse, open, poll_scan, running}; diff --git a/crates/ui-egui/src/dialogs/import/body.rs b/crates/ui-egui/src/dialogs/import/body.rs index 8f82ae51d..0a4b9fb97 100644 --- a/crates/ui-egui/src/dialogs/import/body.rs +++ b/crates/ui-egui/src/dialogs/import/body.rs @@ -1,5 +1,7 @@ +use serde_json::json; + use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; -use super::{Align2, Color32, ImportDialog, LightcraftApp, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, Vec2, pos2, register, vec2}; +use super::{Color32, ImportDialog, LightcraftApp, Rect, Sense, Slot, Stroke, StrokeKind, Tokens, Vec2, pos2, register, vec2}; /// The review dialog's first size (points). It can be resized; the photo grid takes the height. pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; @@ -13,6 +15,43 @@ const CONTENT_BOTTOM: &str = "import-content-bottom"; /// Room below the grid before it has been measured (more than the options and buttons take). pub(super) const DEFAULT_BELOW_GRID: f32 = 360.0; +pub(crate) const THUMB_SIZE_MIN: f32 = 80.0; +pub(crate) const THUMB_SIZE_MAX: f32 = 320.0; +pub(crate) const THUMB_SIZE_DEFAULT: f32 = 116.0; + +/// Bound values from saved preferences and the control channel before laying out the grid. +pub(crate) fn thumb_size(size: f32) -> f32 { + if size.is_finite() { size.clamp(THUMB_SIZE_MIN, THUMB_SIZE_MAX) } else { THUMB_SIZE_DEFAULT } +} + +/// A few physical-pixel sizes avoid decoding on every point of a slider drag. The engine caps +/// candidate previews at 1024 pixels; this covers the largest tile at up to 3× display density. +pub(crate) fn thumb_edge(size: f32, pixels_per_point: f32) -> usize { + let scale = if pixels_per_point.is_finite() && pixels_per_point > 0.0 { pixels_per_point } else { 1.0 }; + let pixels = (thumb_size(size) - 8.0) * scale; + [192, 384, 512, 768, 1024].into_iter().find(|edge| *edge as f32 >= pixels).unwrap_or(1024) +} + +/// Use the shared control contract for pointer, keyboard, exact-value entry and reset interactions. +fn thumb_size_slider(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use lightcraft_develop::controls::{ControlSpec, Section, Track}; + const SIZE: ControlSpec = ControlSpec { + id: "importThumbSize", + label: "Thumbnail size", + section: Section::Light, + min: THUMB_SIZE_MIN as f64, + max: THUMB_SIZE_MAX as f64, + default: THUMB_SIZE_DEFAULT as f64, + step: 1.0, + decimals: 0, + track: Track::Plain, + }; + let out = crate::widgets::slider(ui, &SIZE, f64::from(thumb_size(app.ui.import_thumb_size)), true, None); + if let Some(size) = out.value { + let _ = app.run("view.importThumbSize", json!({"size": SIZE.clamp(size)})); + } +} + /// Height of the photo grid: what is `available` once `below` (options and buttons) is left free, /// in whole points so the window never grows by a fraction each frame, and at least one `row`. pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { @@ -83,8 +122,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { } }); } + thumb_size_slider(app, ui); // candidate grid - let cell = 116.0; + let cell = thumb_size(app.ui.import_thumb_size).min(ui.available_width().max(1.0)); let avail = ui.available_width(); let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize; let rows = n.div_ceil(cols); @@ -95,6 +135,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid) }); let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0); + let mut visible = std::collections::HashSet::new(); let grid = egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover()); @@ -104,11 +145,14 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if !local.intersects(viewport.expand(cell)) { continue; } + let Ok(slot) = u32::try_from(i) else { continue }; + visible.insert(slot); let rect = local.translate(area.min.to_vec2()); candidate_cell(app, ui, d, i, rect); } }); if !ui.is_sizing_pass() { + app.renderer.retain_imports(&visible); ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom())); } ui.add_space(4.0); @@ -388,7 +432,8 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { /// uses its width as height. fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) { let t = Tokens::get(ui.ctx()); - let c = d.candidates[i].clone(); + let Some(c) = d.candidates.get(i).cloned() else { return }; + let Ok(slot_index) = u32::try_from(i) else { return }; let ok = d.importable(i); let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click()); @@ -397,12 +442,9 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial let p = ui.painter(); p.rect_filled(img, 3.0, t.skeleton); // thumbnail (background job; the grid only asks for the cells in view) - let slot = Slot::Import(i as u32); - if !app.renderer.textures.contains_key(&slot) - && let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64) - { - app.renderer.request_quick(slot, job, 4); - } + let slot = Slot::Import(slot_index); + let edge = thumb_edge(img.width(), ui.ctx().pixels_per_point()); + app.renderer.import_preview(&mut app.session, &c, slot_index, edge); if let Some(tex) = app.renderer.textures.get(&slot) { let [tw, th] = tex.size; let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32); @@ -410,7 +452,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) }; p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); } - let on = d.checked[i] && ok; + let on = d.checked.get(i).copied().unwrap_or(false) && ok; if on { p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); } @@ -432,13 +474,20 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial _ => None, }; if let Some(b) = badge { - let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); + let mut label = egui::text::LayoutJob::simple_singleline(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); + label.wrap.max_width = (img.width() - 16.0).max(1.0); + label.wrap.max_rows = 1; + let g = p.layout_job(label); let br = badge_rect(img, cb, g.size()); p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); } - let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::()) } else { c.name.clone() }; - p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim }); + let color = if ok { t.text_label } else { t.text_dim }; + let mut name = egui::text::LayoutJob::simple_singleline(c.name.clone(), t.font(10.5), color); + name.wrap.max_width = (rect.width() - 4.0).max(1.0); + name.wrap.max_rows = 1; + let name = p.layout_job(name); + p.galley(pos2(rect.left() + 2.0, img.bottom() + 2.0), name, color); let tip = format!( "{}\n{} × {} · {} · {:.1} MB{}", c.path, diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index c88a50207..c125a1137 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -678,6 +678,18 @@ fn ui_commands_5(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, p: &Val app.ui.thumb_size = (app.ui.thumb_size + step).clamp(crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); Ok(json!({"size": app.ui.thumb_size})) } + "view.importThumbSize" => { + // {size?: points}; without a size, read the current preference. + let size = match p.get("size") { + None => app.ui.import_thumb_size, + Some(value) => match value.as_f64().map(|n| n as f32).filter(|n| n.is_finite()) { + Some(size) => size, + None => return Some(Err("view.importThumbSize: size must be a finite number".into())), + }, + }; + app.ui.import_thumb_size = crate::import::thumb_size(size); + Ok(json!({"size": app.ui.import_thumb_size})) + } "view.navigate" => { // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). // Validate the complete request before changing either part of the viewport. diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 2759d2197..722115f78 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -99,6 +99,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), + ("view.importThumbSize", "Import Thumbnail Size", None, ""), ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), ("view.clipping", "Clipping", Some("J"), "View"), ("view.visualizeHdr", "Visualize HDR", None, "View"), diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 98356afb7..08e11a79c 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -207,6 +207,8 @@ const VARIANT_PRIORITY: u32 = 6; pub struct Renderer { #[cfg(test)] pub(crate) thumb_jobs_built: usize, + #[cfg(test)] + pub(crate) import_jobs_built: usize, requests: HashMap, /// Only jobs actually accepted by an offload: queued replacements retain identity in the job. offload_looks: HashMap, @@ -303,6 +305,8 @@ impl Renderer { Renderer { #[cfg(test)] thumb_jobs_built: 0, + #[cfg(test)] + import_jobs_built: 0, requests: HashMap::new(), offload_looks: HashMap::new(), #[cfg(test)] @@ -605,6 +609,26 @@ impl Renderer { ); } + /// Prepare a visible import preview at its size bucket, retaining a sharper existing texture. + pub fn import_preview(&mut self, session: &mut Session, candidate: &lightcraft_engine::import::ImportCandidate, index: u32, edge: usize) { + let slot = Slot::Import(index); + let edge = edge.clamp(64, 1024); + if candidate.error.is_some() || self.textures.get(&slot).is_some_and(|tex| tex.size.into_iter().max().unwrap_or(0) >= edge) { + return; + } + let key = lightcraft_engine::import::candidate_thumb_key(candidate, edge); + if self.quick_tried.get(&slot) == Some(&key) { + return; + } + #[cfg(test)] + { + self.import_jobs_built += 1; + } + if let Some(job) = session.candidate_thumb_job(candidate, edge, u64::from(index)) { + self.request_quick(slot, job, 4); + } + } + /// Request a stand-in for `slot` (once per job key). pub fn request_quick(&mut self, slot: Slot, mut job: QuickJob, priority: u32) { if self.quick_tried.get(&slot) == Some(&job.key) { @@ -1144,12 +1168,20 @@ impl Renderer { /// Drop the import review thumbnails (a new review, or the dialog closed). pub fn forget_imports(&mut self) { - self.pool.reprioritize(|s, p| if matches!(s, Slot::Import(_)) { None } else { Some(p) }); - self.queue.retain(|q| !matches!(q.slot, Slot::Import(_))); - self.pending.retain(|s, _| !matches!(s, Slot::Import(_))); - self.requests.retain(|s, _| !matches!(s, Slot::Import(_))); - self.textures.retain(|s, _| !matches!(s, Slot::Import(_))); - self.quick_tried.retain(|s, _| !matches!(s, Slot::Import(_))); + self.retain_imports(&Default::default()); + } + + /// Keep only the import tiles near the viewport, including queued work. Large thumbnails + /// must not accumulate for every candidate while scrolling through a whole card. + pub fn retain_imports(&mut self, keep: &std::collections::HashSet) { + let retained = |s: &Slot| !matches!(s, Slot::Import(i) if !keep.contains(i)); + self.pool.reprioritize(|s, p| retained(s).then_some(p)); + self.queue.retain(|q| retained(&q.slot)); + self.pending.retain(|s, _| retained(s)); + self.requests.retain(|s, _| retained(s)); + self.textures.retain(|s, _| retained(s)); + self.quick_tried.retain(|s, _| retained(s)); + self.failed.retain(|s, _| retained(s)); } /// Drop thumbnails that aren't in `keep` (bounded memory for huge libraries), and queued diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 38b50902c..908c6fa81 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -70,6 +70,8 @@ pub struct UiState { pub zoom_anim: bool, pub before_after: BeforeAfter, pub thumb_size: f32, + /// Import review thumbnail size in points, independent of the library grid. + pub import_thumb_size: f32, /// Open Edit sections by id. pub open_sections: Vec, /// Open flyouts (curve, mixer, grading…). @@ -355,6 +357,7 @@ pub struct UiState { } impl Default for UiState { + /// Initialize the library and import review sizes as independent app preferences. fn default() -> Self { UiState { window: None, @@ -380,6 +383,7 @@ impl Default for UiState { zoom_anim: false, before_after: BeforeAfter::Off, thumb_size: 220.0, + import_thumb_size: crate::import::THUMB_SIZE_DEFAULT, open_sections: vec!["light".into()], open_flyouts: vec![], single_panel: false, @@ -569,6 +573,7 @@ impl UiState { /// Clamp values restored from disk. pub fn sanitized(mut self) -> Self { self.thumb_size = self.thumb_size.clamp(THUMB_SIZE_MIN, THUMB_SIZE_MAX); + self.import_thumb_size = crate::import::thumb_size(self.import_thumb_size); self.left_width = LEFT_WIDTH.clamp(self.left_width); self.right_width = RIGHT_WIDTH.clamp(self.right_width); self.brush_size = self.brush_size.clamp(0.002, 0.5); diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index 3a102a29a..7ccf8ded0 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -31,6 +31,7 @@ mod grading; mod grid; mod guided_guides; mod histogram_drag; +mod import_thumbnails; mod info_dialogs; mod keymap; mod keyword_files; diff --git a/crates/ui-egui/src/tests/import_thumbnails.rs b/crates/ui-egui/src/tests/import_thumbnails.rs new file mode 100644 index 000000000..6a1b0c104 --- /dev/null +++ b/crates/ui-egui/src/tests/import_thumbnails.rs @@ -0,0 +1,276 @@ +//! Import review sizes change the real grid and its preview resolution, preserving culling. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::import::ImportDialog; +use crate::render::Slot; +use crate::state::{Dialog, UiState}; +use crate::{LightcraftApp, Services}; +use lightcraft_engine::import::ImportCandidate; + +const T: Duration = Duration::from_secs(30); + +/// Size buckets follow physical pixels, with finite fallbacks and a fixed allocation ceiling. +#[test] +fn import_thumb_edges_follow_size_and_display_density() { + use crate::import::thumb_edge; + assert_eq!(thumb_edge(116.0, 1.0), 192); + assert_eq!(thumb_edge(320.0, 1.0), 384); + assert_eq!(thumb_edge(320.0, 2.0), 768); + assert_eq!(thumb_edge(320.0, 3.0), 1024); + assert_eq!(thumb_edge(320.0, f32::MAX), 1024); + assert_eq!(thumb_edge(f32::NAN, f32::NAN), 192); + assert_eq!(thumb_edge(116.0, -1.0), 192); +} + +/// An isolated import dialog with real preview jobs and a disposable procedural source file. +struct Review { + h: Headless, + dir: std::path::PathBuf, +} + +impl Review { + /// Open sixty candidates at the requested display density, including duplicate/error badges. + fn new(scale: f32) -> Self { + Self::with_dimensions(scale, 1600, 1200) + } + + fn with_dimensions(scale: f32, width: usize, height: usize) -> Self { + let dir = std::env::temp_dir().join(format!("lc-import-thumbnails-{}-{}", std::process::id(), lightcraft_preview::next_tick())); + std::fs::create_dir_all(&dir).unwrap(); + let image = lightcraft_raster::Rgba8 { + width, + height, + data: (0..width * height).map(|i| [((i % width) / 7) as u8, ((i / width) / 5) as u8, 100, 255]).collect(), + }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &Default::default()).unwrap(); + let path = dir.join("original.png"); + std::fs::write(&path, &bytes).unwrap(); + let mut candidates: Vec<_> = (0..60) + .map(|i| ImportCandidate { + path: path.to_string_lossy().into(), + name: format!("a-long-photo-name-{i:02}.png"), + format: "PNG".into(), + width: width as u32, + height: height as u32, + file_size: bytes.len() as u64, + ..Default::default() + }) + .collect(); + candidates[1].duplicate = Some("content".into()); + candidates[2].error = Some("Unreadable test candidate".into()); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + app.ui.dialog = Some(Dialog::Import { opts: Box::new(ImportDialog::new(candidates)) }); + let mut h = Headless::new(app, [1366.0, 1000.0], scale); + assert!(h.settle(T)); + Self { h, dir } + } + + /// Find an automation widget, failing the test if the dialog no longer exposes it. + fn rect(&self, id: &str) -> egui::Rect { + self.h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("missing {id}")) + } + + /// Snapshot every candidate's checked state while requiring the review to remain open. + fn checks(&self) -> Vec { + let Some(Dialog::Import { opts }) = &self.h.app.ui.dialog else { panic!("import review closed") }; + opts.checked.clone() + } + + /// Drive the real control dispatcher and fail on any unsuccessful response. + fn request(&mut self, method: &str, params: serde_json::Value) { + let response = self.h.request(method, params, T); + assert_eq!(response["ok"], true, "{method}: {response}"); + } + + /// Set thumbnail size through its command and wait for the resulting previews to settle. + fn size(&mut self, size: f32) { + self.request("engine.execute", json!({"command": "view.importThumbSize", "params": {"size": size}})); + assert!(self.h.settle(T)); + } +} + +impl Drop for Review { + /// Release import preview jobs before removing the temporary source directory. + fn drop(&mut self) { + self.h.app.ui.dialog = None; + self.h.app.renderer.forget_imports(); + self.h.settle(T); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Pointer input must resize and reflow tiles without changing culling, badges or saved size. +#[test] +fn import_thumbnail_slider_reflows_without_changing_selection_or_badges() { + let mut review = Review::new(1.0); + assert_eq!(review.rect("import:0").width(), 116.0); + review.request("ui.clickWidget", json!({"id": "import:0"})); + let checked = review.checks(); + assert!(!checked[0] && !checked[1] && !checked[2]); + // Real pointer input on the slider, including its end stops. + review.request("ui.clickWidget", json!({"id": "slider:importThumbSize", "fx": 0.0})); + assert!(review.h.settle(T)); + assert_eq!(review.rect("import:0").width(), 80.0); + let slider = review.rect("slider:importThumbSize"); + review.request("ui.dragWidget", json!({"id": "slider:importThumbSize", "toX": slider.right(), "toY": slider.center().y})); + assert!(review.h.settle(T)); + assert_eq!(review.rect("import:0").width(), 320.0); + assert_eq!(review.checks(), checked); + let text = review.h.painted_text().join("\n"); + assert!(text.contains("Duplicate") && text.contains("Unreadable"), "badges remain visible: {text}"); + assert!(review.rect("import:3").top() > review.rect("import:0").top(), "larger tiles wrap into more rows"); + review.size(80.0); + assert_eq!(review.rect("import:3").top(), review.rect("import:0").top()); + assert_eq!(review.checks(), checked); + // The preference survives closing/reopening the review and a settings round trip. + let saved = serde_json::to_value(&review.h.app.ui).unwrap(); + let restored: UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(restored.sanitized().import_thumb_size, 80.0); +} + +/// Dense displays need sharper previews; shrinking reuses them and scrolling bounds retention. +#[test] +fn import_thumbnail_previews_upgrade_for_density_and_stay_bounded_when_scrolling() { + let mut review = Review::new(2.0); + assert_eq!(review.h.app.renderer.textures[&Slot::Import(0)].size, [384, 288]); + review.size(320.0); + assert_eq!(review.h.app.renderer.textures[&Slot::Import(0)].size, [768, 576]); + // Shrinking keeps the sharp preview instead of replacing it with a smaller decode. + let sharp = review.h.app.renderer.textures[&Slot::Import(0)].key; + review.size(116.0); + assert_eq!(review.h.app.renderer.textures[&Slot::Import(0)].key, sharp); + review.size(320.0); + // Scrolling a large card retains only the nearby tiles and drops obsolete requests. + review.request("ui.hoverWidget", json!({"id": "import:0"})); + review.request("ui.scroll", json!({"dy": -20000.0})); + assert!(review.h.settle(T)); + assert!(!review.h.app.renderer.textures.contains_key(&Slot::Import(0))); + let imports: Vec<_> = review.h.app.renderer.textures.keys().filter_map(|s| if let Slot::Import(i) = s { Some(*i) } else { None }).collect(); + assert!(!imports.is_empty() && imports.len() < 20, "bounded visible previews: {imports:?}"); + for i in imports { + assert!(review.h.app.widgets.iter().any(|(w, _)| w == &format!("import:{i}"))); + } +} + +/// A preview smaller than its requested bucket is still built only once while the grid is idle. +#[test] +fn import_thumbnail_small_sources_do_not_rebuild_jobs_every_frame() { + let mut review = Review::with_dimensions(2.0, 64, 48); + assert_eq!(review.h.app.renderer.textures[&Slot::Import(0)].size, [64, 48]); + for size in [116.0, 320.0, 80.0] { + review.size(size); + let built = review.h.app.renderer.import_jobs_built; + for _ in 0..60 { + review.h.step(); + } + assert_eq!(review.h.app.renderer.import_jobs_built, built, "small sources already satisfy their requested bucket"); + } +} + +/// Closing the review releases large previews through the button, Escape and control channel. +#[test] +fn import_thumbnail_close_releases_previews_for_all_cancel_paths() { + for dismissal in ["button", "escape", "control"] { + let mut review = Review::new(1.0); + assert!(review.h.app.renderer.textures.contains_key(&Slot::Import(0))); + match dismissal { + "escape" => review.request("ui.key", json!({"key": "Escape"})), + "control" => review.request("ui.dialog.cancel", json!({})), + _ => review.request("ui.clickWidget", json!({"id": "button:dialogCancel"})), + } + assert!(review.h.settle(T)); + assert!(review.h.app.ui.dialog.is_none()); + assert!(review.h.app.renderer.textures.keys().all(|slot| !matches!(slot, Slot::Import(_)))); + assert!(!review.h.app.renderer.is_pending(Slot::Import(0))); + } +} + +/// Invalid commands preserve the setting; legacy and non-finite preferences restore safely. +#[test] +fn import_thumbnail_size_rejects_bad_commands_and_restores_old_preferences() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + assert_eq!(app.run("view.importThumbSize", json!({})).unwrap()["size"], 116.0); + for bad in [json!("large"), json!(null), json!([120]), json!(1e300)] { + assert!(app.run("view.importThumbSize", json!({"size": bad})).is_err()); + assert_eq!(app.ui.import_thumb_size, 116.0); + } + assert_eq!(app.run("view.importThumbSize", json!({"size": -10})).unwrap()["size"], 80.0); + assert_eq!(app.run("view.importThumbSize", json!({"size": 1e6})).unwrap()["size"], 320.0); + let legacy: UiState = serde_json::from_value(json!({})).unwrap(); + assert_eq!(legacy.import_thumb_size, 116.0); + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let state = UiState { import_thumb_size: bad, ..Default::default() }.sanitized(); + assert_eq!(state.import_thumb_size, 116.0); + } +} + +/// A selected import slider keeps keyboard nudges after idle frames without changing candidate checks. +#[test] +fn import_thumbnail_slider_keeps_the_shared_keyboard_contract() { + let mut review = Review::new(1.0); + let checked = review.checks(); + review.request("ui.clickWidget", json!({"id": "slider:importThumbSize", "fx": 0.4})); + assert!(review.h.settle(T)); + for _ in 0..6 { + review.h.step(); + } + let before = review.h.app.ui.import_thumb_size; + review.request("ui.key", json!({"key": "ArrowUp"})); + assert!(review.h.settle(T)); + assert_eq!(review.h.app.ui.import_thumb_size, before + 1.0); + assert_eq!(review.checks(), checked); +} + +/// Escape cancels the numeric draft before a later Escape dismisses the enclosing review. +#[test] +fn import_thumbnail_exact_entry_escape_keeps_the_review_open() { + let mut review = Review::new(1.0); + let checked = review.checks(); + for (key, expected) in [("Escape", 116.0), ("Enter", 240.0)] { + review.request("ui.clickWidget", json!({"id": "sliderValue:importThumbSize"})); + review.request("ui.key", json!({"key": "a", "cmd": true})); + review.request("ui.text", json!({"text": "240"})); + review.request("ui.key", json!({"key": key})); + assert!(review.h.settle(T)); + assert_eq!(review.h.app.ui.import_thumb_size, expected); + assert_eq!(review.checks(), checked, "the review remains open after exact-value editing"); + } + review.request("ui.key", json!({"key": "Escape"})); + assert!(review.h.app.ui.dialog.is_none(), "a later Escape still dismisses the review"); +} + +/// Every shipped language translates the slider and command labels, with the real control visible at high density. +#[test] +fn import_thumbnail_labels_are_localized_in_every_shipped_language() { + use crate::i18n::Locale; + let command = + crate::menus::UI_COMMANDS.iter().find(|command| command.0 == "view.importThumbSize").expect("the import-size command is registered"); + assert_eq!(command.1, "Import Thumbnail Size", "the catalog key matches the command's actual label"); + for theme in ["charcoal", "paper"] { + for scale in [1.0, 2.25] { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }); + app.ui.dialog = Some(Dialog::Import { opts: Box::new(ImportDialog::new(Vec::new())) }); + let mut h = Headless::new(app, [960.0, 720.0], scale); + assert!(h.app.run("view.theme", json!({"theme": theme})).is_ok()); + for locale in Locale::ALL { + for source in ["Thumbnail size", "Import Thumbnail Size", "Click to type a value, drag to change it"] { + assert!(!locale.tr(source).is_empty()); + if *locale != Locale::En { + assert_ne!(locale.tr(source), source, "{locale:?}: {source}"); + } + } + let result = h.request("ui.set", json!({"language": locale.code()}), T); + assert_eq!(result["ok"], true, "{result}"); + assert!(h.settle(T)); + assert!(h.painted_text().iter().any(|text| text == locale.tr("Thumbnail size")), "{locale:?}: translated slider label"); + let track = h.app.widgets.iter().find(|(id, _)| id == "slider:importThumbSize").expect("the import slider is visible").1; + let viewport = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + assert!(viewport.contains_rect(track), "{locale:?} {theme} {scale}: slider stays inside the viewport"); + } + } + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 89185d991..fcb01710b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -662,6 +662,9 @@ fn edit_value(ui: &mut Ui, t: &Tokens, g: &SliderLayout, draft: Draft, out: &mut if field.ending.is_some() || !field.editing || !g.enabled { // Esc (or the slider turning off) keeps the old value; Return or clicking away applies let cancelled = !g.enabled || field.cancelled(); + if field.cancelled() { + take_escape(ui.ctx()); + } if let Some(nv) = typed_value(g.spec, &text).filter(|nv| !cancelled && g.differs(*nv)) { g.set_whole(out, nv); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 36294c787..22d0723b2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -312,6 +312,16 @@ For MCP, use `lightcraft-cli mcp --connect 127.0.0.1:7980`: `run_command` reache `lightcraft://controls` ([mcp.md](mcp.md#resources)). Headless CLI/MCP mode owns a separate engine session; use connect mode for feedback from edits in the desktop app. +The import review's **Thumbnail size** slider is `slider:importThumbSize` in `ui.widgets`. +`engine.execute {command: "view.importThumbSize", params: {size: 240}}` sets its size in points +(80–320, clamped); omitting `size` reads it. Both return `{size}`. Non-numeric or non-finite +values are refused. The preference is `importThumbSize` in the UI state, independent of the +library grid's `thumbSize`; resizing keeps the import checkboxes and duplicate markers. +The shared slider also exposes `sliderValue:importThumbSize` for exact entry and +`sliderLabel:importThumbSize` for double-click reset to 116 pt. Up/Down nudges the selected +slider by 1 pt; Return commits typed values and Escape cancels them. Slider and command +labels, including edit/reset feedback, are localized in every shipped catalog. Previews use bounded physical-pixel buckets through 1024 px. A smaller source is tried once per bucket; scrolling releases offscreen textures, queued work and request identities. A renderable raw uses a render when its camera JPEG cannot cover the tile; preview-only raws, unavailable renders and failed renders retain the available camera JPEG. + ## Headless rendering (no window, no GPU) The egui UI can be rasterized on the CPU (`crates/ui-egui/src/softpaint.rs`, driven by diff --git a/docs/gaps.md b/docs/gaps.md index 8918541c7..e912040e1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** minor (import review: remembered thumbnail size and density-aware previews (#2237, part of #2056); pre-import loupe/keyboard culling remains open; no ranked gap closes; gap 8: lens profile work is tracked in #329; gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -61,6 +61,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w ## Closed recently (kept for calibration) +- 2026-10-29: the import review has a remembered 80–320 pt thumbnail-size slider, density-aware previews and bounded visible retention (#2237, part of #2056). Small sources do not reconstruct the same preview job every frame. The pre-import loupe and keyboard culling remain open in #2056; no ranked gap or headline feature count closes. + - 2026-10-22: saving XMP preserves foreign namespaces even when they use `lc:` or `xmp:` prefixes; importing them no longer interprets their properties as native settings or ratings. Parser, merge and save-command regressions cover the correction; no ranked gap or readiness estimate changes. - 2026-10-22: PSD/PSB imports and metadata readers now expose embedded EXIF resource 1058 and XMP resource 1060 for capture dates, GPS and All Metadata (#2519); imported PSD edit copies retain their capture date, and the `removeLocation` Original-export guard sees embedded PSD/PSB GPS. No ranked gap or headline feature count closes. @@ -84,6 +86,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Import thumbnails render when the camera JPEG is too small, retaining preview-only/error fallbacks; import review: remembered thumbnail size and density-aware previews (#2237, part of #2056); pre-import loupe/keyboard culling remains open; no ranked gap closes | | 2026-10-24 | minor | Gap 8: lens profile work is tracked in #329; hours unchanged | | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ead8e6f14..1d801329e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **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 @@ -69,7 +69,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/dialogs/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/widgets/album_picker.rs`, `crates/ui-egui/src/tests/album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests/import.rs`) | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:view.importThumbSize`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/dialogs/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with a remembered 80–320 pt thumbnail-size slider (default 116, independent of the library grid), density-aware previews capped at 1024 px and retention limited to nearby cells; tiny sources are tried once per requested size bucket; renderable raws use a sharper render when the camera JPEG is undersized, retaining the JPEG for preview-only raws or an unavailable/failed render; the pre-import loupe and keyboard culling remain open under #2056; thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new): the destination picker follows the sidebar folder hierarchy and order, searches full paths, and distinguishes duplicate names by their folder path; folders navigate and smart albums are excluded, with None, New album and album-context preselection retained (#2362; `crates/ui-egui/src/widgets/album_picker.rs`, `crates/ui-egui/src/tests/album_picker.rs`); preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests/import.rs`) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → the import review), `crates/ui-egui/src/drop_overlay.rs`, `crates/ui-egui/src/tests/drop_import.rs` | while files are dragged over the window a veil and a card say what dropping does (review N items for import, import N presets, open the face model; a note to close the open dialog first when one is open); photos and folders open the import review as File ▸ Import does (Settings ▸ Import ▸ Import dropped files without review imports them at once; the web build always does); with no library open on the desktop (the launch list, a library that couldn't open) photos wait for the library opened and presets are refused, and the card says so; a drop while a dialog is open (also in the web build) or while an import is running is refused with a toast, and the card says so; a finished scan waits for an open dialog to close before its review opens; the control channel's `ui.dropFiles` hovers or drops files for scripts; dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests/import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/dialogs/import.rs`) | @@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket, with sharper raw fallback and the available camera JPEG retained when rendering cannot help (#2237, part of #2056); import loupe remains open; counts unchanged | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |