mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 21:48:53 -06:00
Merge PR #2237: LR-IMP-ADD-DIALOG: remembered import thumbnail-size slider
docs/gaps.md, docs/parity-checklist.md: header keeps the newer date plus both sides' items; revision row added newest first. locales/ru.json: union of keys.
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<usize>, renderable: bool, preview_only: bool) -> (Session, ImportCandidate, Arc<AtomicUsize>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@ pub struct QuickJob {
|
||||
pub cached: Vec<(Arc<PreviewCache>, 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<usize>,
|
||||
/// Last resort: run this thumbnail job.
|
||||
pub small: Option<Box<RenderJob>>,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Rgba8> { panic!("preview reader bug") }), 256)),
|
||||
minimum_embedded_edge: None,
|
||||
small: None,
|
||||
};
|
||||
let r = quick.run();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "読み込みサムネイルのサイズ"
|
||||
}
|
||||
|
||||
@@ -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": "დააწკაპუნეთ მნიშვნელობის შესაყვანად, გადაათრიეთ მის შესაცვლელად"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Размер миниатюр при импорте"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Розмір мініатюр імпорту"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "导入缩略图大小"
|
||||
}
|
||||
|
||||
@@ -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": "匯入縮圖大小"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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::<String>()) } 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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Slot, Requested>,
|
||||
/// Only jobs actually accepted by an offload: queued replacements retain identity in the job.
|
||||
offload_looks: HashMap<u64, (u64, bool)>,
|
||||
@@ -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<u32>) {
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<bool> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-1
@@ -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) |
|
||||
|
||||
@@ -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 🟡) |
|
||||
|
||||
Reference in New Issue
Block a user