diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 5fc2092a9..d85332b23 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -33,4 +33,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: require-craft-fonts: true - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index d992f7433..01cb9cd66 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,11 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: nix flake check --no-build --all-systems + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e24229356..b8fb01326 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,8 @@ jobs: with: app-name: lightcraft app-title: LightCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/Cargo.lock b/Cargo.lock index a49ec38db..c1bd70fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3078,7 +3078,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.10.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -3103,7 +3103,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crc32fast", "lightcraft-develop", @@ -3120,7 +3120,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3136,7 +3136,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.10.0" +version = "0.11.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3167,7 +3167,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3176,7 +3176,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.10.0" +version = "0.11.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3193,7 +3193,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3208,7 +3208,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3220,7 +3220,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.10.0" +version = "0.11.0" dependencies = [ "ab_glyph", "craft-docpath", @@ -3263,7 +3263,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-testkit", "matrixmultiply", @@ -3278,7 +3278,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.10.0" +version = "0.11.0" dependencies = [ "log", "rustls", @@ -3289,7 +3289,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.10.0" +version = "0.11.0" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3297,7 +3297,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.10.0" +version = "0.11.0" dependencies = [ "proptest", "serde", @@ -3305,7 +3305,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.10.0" +version = "0.11.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3325,7 +3325,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.10.0" +version = "0.11.0" dependencies = [ "heic-rs", "rayon", @@ -3333,14 +3333,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.10.0" +version = "0.11.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3349,7 +3349,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3360,7 +3360,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3376,7 +3376,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3388,7 +3388,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.10.0" +version = "0.11.0" dependencies = [ "image", "lightcraft-color", @@ -3406,7 +3406,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.10.0" +version = "0.11.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3418,7 +3418,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.10.0" +version = "0.11.0" dependencies = [ "libc", "lightcraft-color", @@ -3430,7 +3430,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.10.0" +version = "0.11.0" dependencies = [ "dhat", "jpeg-encoder", @@ -3454,7 +3454,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.10.0" +version = "0.11.0" dependencies = [ "image", "lightcraft-color", @@ -3465,7 +3465,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.10.0" +version = "0.11.0" dependencies = [ "candle-core", "candle-nn", @@ -3479,7 +3479,7 @@ dependencies = [ [[package]] name = "lightcraft-slideshow" -version = "0.10.0" +version = "0.11.0" dependencies = [ "lightcraft-raster", "serde", @@ -3488,15 +3488,15 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.10.0" +version = "0.11.0" [[package]] name = "lightcraft-testkit" -version = "0.10.0" +version = "0.11.0" [[package]] name = "lightcraft-tiff" -version = "0.10.0" +version = "0.11.0" dependencies = [ "proptest", "serde", @@ -3505,7 +3505,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-ui", "egui", @@ -3534,7 +3534,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crc32fast", "eframe", @@ -7004,7 +7004,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index c76095d2d..9b5c404a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 01dae3055..c8371cbb9 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -732,12 +732,13 @@ fn render(args: &[String]) -> Result<(), String> { for (k, v) in opts { p[k] = v; } - let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?; if p.get("format").is_none() { let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); - o.format = ExportFormat::parse(&ext) - .ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?; + ExportFormat::parse(&ext).ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?; + // given before validating, so `bitDepth` is checked against the format written (issue #1952) + p["format"] = json!(ext); } + let o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?; // Add to Apple Photos: refused before anything is written when it can't be done let after = s.after_export(&o)?; let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?; diff --git a/apps/lightcraft-web/src/backend.rs b/apps/lightcraft-web/src/backend.rs index e20f6dc9c..1dbc2266b 100644 --- a/apps/lightcraft-web/src/backend.rs +++ b/apps/lightcraft-web/src/backend.rs @@ -22,6 +22,12 @@ fn err(e: JsValue) -> String { e.as_string().or_else(|| e.dyn_ref::().map(|e| String::from(e.message()))).unwrap_or_else(|| format!("{e:?}")) } +/// Did an OPFS call fail because the entry doesn't exist (`NotFoundError`)? Any other failure +/// (security, type mismatch, an unreadable or evicted store) is an error, never "absent" (#1507). +fn not_found(e: &JsValue) -> bool { + crate::files::is_not_found(Reflect::get(e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref()) +} + /// `navigator.storage` in a window or a worker. fn storage_manager() -> Option { let g = js_sys::global(); @@ -134,10 +140,7 @@ impl Backend { opts.set_create(create); match JsFuture::from(dir.get_directory_handle_with_options(p, &opts)).await { Ok(d) => dir = d.unchecked_into(), - Err(e) if !create => { - let _ = e; - return Ok(None); - } + Err(e) if !create && not_found(&e) => return Ok(None), Err(e) => return Err(err(e)), } } @@ -150,7 +153,7 @@ impl Backend { opts.set_create(create); match JsFuture::from(dir.get_file_handle_with_options(&name, &opts)).await { Ok(h) => Ok(Some(h.unchecked_into())), - Err(_) if !create => Ok(None), + Err(e) if !create && not_found(&e) => Ok(None), Err(e) => Err(err(e)), } } @@ -250,7 +253,7 @@ impl Backend { let Some((dir, name)) = Self::parent(root, path, false).await? else { return Ok(()) }; match JsFuture::from(dir.remove_entry(&name)).await { Ok(_) => Ok(()), - Err(e) if Reflect::get(&e, &"name".into()).ok().and_then(|n| n.as_string()).as_deref() == Some("NotFoundError") => Ok(()), + Err(e) if not_found(&e) => Ok(()), Err(e) => Err(err(e)), } } diff --git a/apps/lightcraft-web/src/backup.rs b/apps/lightcraft-web/src/backup.rs index 00ec12a41..281a32296 100644 --- a/apps/lightcraft-web/src/backup.rs +++ b/apps/lightcraft-web/src/backup.rs @@ -52,6 +52,18 @@ pub fn valid_hash(h: &str) -> bool { !h.is_empty() && h.len() <= 128 && h.chars().all(|c| c.is_ascii_alphanumeric()) } +/// Why a backup can't start yet, given [`lightcraft_engine::Session::unsaved`]: changes applied in +/// the app but not yet written to browser storage (a save failed). The zip copies the stored +/// library files, so it would report success while missing them; `None` when everything is saved. +pub fn unsaved_refusal(unsaved: Option<(usize, &str)>) -> Option { + let (ops, why) = unsaved?; + let changes = if ops == 1 { "1 change".to_string() } else { format!("{ops} changes") }; + Some(format!( + "Backup not started: {changes} could not be saved to browser storage yet ({why}), so the backup would miss them. \ + Free up storage space; LightCraft retries the save with every change. Back up once nothing is left unsaved." + )) +} + /// Zip entry name for an original. pub fn original_entry(hash: &str, file_name: &str) -> String { let name: String = file_name.chars().map(|c| if c == '/' || c == '\\' || c.is_control() { '_' } else { c }).collect(); diff --git a/apps/lightcraft-web/src/files.rs b/apps/lightcraft-web/src/files.rs index d0c9f348b..9cd934ce5 100644 --- a/apps/lightcraft-web/src/files.rs +++ b/apps/lightcraft-web/src/files.rs @@ -198,6 +198,27 @@ impl Store for FilesStore { /// Names of the library files read at start-up. pub const LIBRARY_FILES: [&str; 6] = ["catalog.snap", "catalog.log", "presets.json", "view.json", "prefs.json", "ui.json"]; +/// Is a browser storage error, by its `DOMException` name, "the entry doesn't exist"? Only +/// `NotFoundError` is; anything else (security, type mismatch, an unreadable store) is a failure. +pub fn is_not_found(dom_exception_name: Option<&str>) -> bool { + dom_exception_name == Some("NotFoundError") +} + +/// Preload the library files read at start-up (`None`: the file doesn't exist yet). A file that +/// exists but couldn't be read stops start-up instead (#1507): opening the library without it +/// would show an incomplete catalog and then save over the stored copy. +pub fn preload_library(files: &Files, reads: Vec<(&str, Result>, String>)>) -> Result<(), String> { + let mut failed = Vec::new(); + for (name, read) in reads { + match read { + Ok(Some(data)) => files.preload(name, data), + Ok(None) => {} + Err(e) => failed.push(format!("{name}: {e}")), + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("couldn't read the library from browser storage ({})", failed.join("; "))) } +} + #[cfg(test)] mod tests { use super::*; @@ -282,6 +303,66 @@ mod tests { assert_eq!(s2.catalog.photo(id).unwrap().rating, 5, "the rating survived"); } + /// A backup copies the stored library files, so while a save is failing it would miss the + /// queued changes: it is refused with the reason until they are written (#1508). + #[test] + fn backup_is_refused_while_changes_are_unsaved() { + let files = Files::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "browser".into(), catalog: Box::new(files.store()), files: Box::new(files.store()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "everything saved: the backup may start"); + files.set_error(Some("QuotaExceededError".into())); + let _ = s.execute("photo.rate", &json!({"rating": 5})); + let refusal = crate::backup::unsaved_refusal(s.unsaved()).expect("refused while the rating is unsaved"); + assert!(refusal.contains("could not be saved") && refusal.contains("QuotaExceeded"), "{refusal}"); + files.set_error(None); + s.persist_if_dirty(); + s.persist().unwrap(); + assert_eq!(crate::backup::unsaved_refusal(s.unsaved()), None, "allowed again once the change is written"); + } + + #[test] + fn unsaved_refusal_counts_changes() { + use crate::backup::unsaved_refusal; + assert!(unsaved_refusal(Some((1, "quota"))).unwrap().contains("1 change could not")); + assert!(unsaved_refusal(Some((3, "quota"))).unwrap().contains("3 changes could not")); + } + + /// Start-up only treats a missing file as absent: a stored file that couldn't be read stops + /// it, naming the file, instead of opening an incomplete library (#1507). + #[test] + fn unreadable_library_file_stops_start_up() { + let files = Files::default(); + let reads = vec![ + ("catalog.snap", Ok(Some(b"snap".to_vec()))), + ("catalog.log", Err("NotReadableError: the file could not be read".to_string())), + ("presets.json", Ok(None)), + ]; + let e = preload_library(&files, reads).unwrap_err(); + assert!(e.contains("catalog.log") && e.contains("NotReadableError"), "{e}"); + } + + /// A new library (nothing stored yet) still starts. + #[test] + fn missing_library_files_are_a_new_library() { + let files = Files::default(); + let reads = LIBRARY_FILES.iter().map(|n| (*n, Ok(None))).collect(); + preload_library(&files, reads).unwrap(); + assert!(files.get("catalog.snap").is_none()); + let reads = vec![("catalog.snap", Ok(Some(b"snap".to_vec()))), ("catalog.log", Ok(None))]; + preload_library(&files, reads).unwrap(); + assert_eq!(files.get("catalog.snap").as_deref(), Some(&b"snap"[..])); + } + + #[test] + fn only_not_found_errors_mean_absent() { + assert!(is_not_found(Some("NotFoundError"))); + for name in [Some("SecurityError"), Some("TypeMismatchError"), Some("NotReadableError"), Some("InvalidStateError"), None] { + assert!(!is_not_found(name), "{name:?}"); + } + } + #[test] fn library_survives_reload_through_flushes() { let mut disk = BTreeMap::new(); diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 6a4487d09..14a5d050b 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -173,6 +173,9 @@ fn services(originals: Originals, backend: Option, files: Files, frozen screen_profile: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; + if let Some(why) = crate::backup::unsaved_refusal(session.unsaved()) { + return Err(why); + } // the photos' own names for the originals in the zip let names: std::collections::HashMap = session .catalog @@ -280,6 +283,9 @@ struct Boot { index: ThumbIndex, /// The library folder in storage (`library`, or a restored backup's). lib_dir: String, + /// Library files that exist but couldn't be read (#1507): the library isn't opened and + /// nothing is saved, so the stored copy stays as it was. + load_error: Option, } async fn boot(opts: Options) -> Boot { @@ -298,6 +304,7 @@ async fn boot(opts: Options) -> Boot { let files = Files::default(); let mut index = ThumbIndex::default(); let mut lib_dir = DEFAULT_LIBRARY_DIR.to_string(); + let mut load_error = None; if let Some(b) = &backend { if opts.reset { // everything this site keeps in the browser, photos included: never without asking @@ -315,21 +322,32 @@ async fn boot(opts: Options) -> Boot { } } // a restored backup lives in its own folder (see `backup`) - if let Ok(Some(d)) = b.read(ACTIVE_LIBRARY).await { - let d = String::from_utf8_lossy(&d).trim().to_string(); - if valid_library_dir(&d) { - lib_dir = d; + match b.read(ACTIVE_LIBRARY).await { + Ok(Some(d)) => { + let d = String::from_utf8_lossy(&d).trim().to_string(); + if valid_library_dir(&d) { + lib_dir = d; + } + } + Ok(None) => {} + // not knowing which library is in use, any library opened could be the wrong one + Err(e) => load_error = Some(format!("couldn't read which library is in use from browser storage ({e})")), + } + if load_error.is_none() { + let mut reads = Vec::new(); + for name in LIBRARY_FILES { + reads.push((name, b.read(&library_key(&lib_dir, name)).await)); + } + if let Err(e) = crate::files::preload_library(&files, reads) { + log::error!("{e}"); + load_error = Some(e); } } - for name in LIBRARY_FILES { - match b.read(&library_key(&lib_dir, name)).await { - Ok(Some(data)) => files.preload(name, data), - Ok(None) => {} - Err(e) => log::error!("reading {name}: {e}"), - } - } - if let Ok(Some(j)) = b.read(THUMB_INDEX).await { - index = ThumbIndex::from_json(&j); + let index_read = b.read(THUMB_INDEX).await; + match &index_read { + Ok(Some(j)) => index = ThumbIndex::from_json(j), + Ok(None) => {} + Err(e) => log::warn!("reading the thumbnail index: {e}"), } crate::backend::request_persistence(|granted| { if !granted { @@ -339,7 +357,8 @@ async fn boot(opts: Options) -> Boot { ); } }); - // thumbnails stored without an index entry (index saved late, or lost): delete them + // thumbnails stored without an index entry (index saved late, or lost): delete them; not + // when the index couldn't be read, which would make every thumbnail look unindexed let b2 = b.clone(); let known: std::collections::HashSet = b .list("thumbs") @@ -349,7 +368,7 @@ async fn boot(opts: Options) -> Boot { .filter(|n| n.ends_with(".jpg")) .map(|n| n.trim_end_matches(".jpg").to_string()) .collect(); - let orphans: Vec = known.into_iter().filter(|k| !index.contains(k)).collect(); + let orphans: Vec = if index_read.is_ok() { known.into_iter().filter(|k| !index.contains(k)).collect() } else { Vec::new() }; if !orphans.is_empty() { wasm_bindgen_futures::spawn_local(async move { for k in &orphans { @@ -366,7 +385,7 @@ async fn boot(opts: Options) -> Boot { index.len(), index.total() as f64 / 1e6 ); - Boot { opts, backend, files, index, lib_dir } + Boot { opts, backend, files, index, lib_dir, load_error } } struct WebApp { @@ -389,7 +408,7 @@ struct WebApp { impl WebApp { fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec) -> Self { - let Boot { opts, backend, files, index, lib_dir } = boot; + let Boot { opts, backend, files, index, lib_dir, load_error } = boot; let originals = Originals::default(); let t = perf_now(); let mut session = Session::new(); @@ -401,7 +420,13 @@ impl WebApp { on_disk: false, }; let mut problem = None; - match session.open_library_in(stores, true).cloned() { + // a library that couldn't be read is not opened: it would show incomplete, then save over the stored copy + let read_failed = load_error.is_some(); + let opened = match load_error { + Some(e) => Err(format!("{e}. Nothing was changed in storage")), + None => session.open_library_in(stores, true).cloned().map_err(|e| e.to_string()), + }; + match opened { Ok(r) => log::info!( "lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)", if r.created { "created" } else { "loaded" }, @@ -432,7 +457,8 @@ impl WebApp { } // previews are large (≤ 2560 px, f32): keep few in a 32-bit address space session.media.preview_capacity = 3; - let frozen = Rc::new(Cell::new(false)); + // nothing is saved while the stored library couldn't be read + let frozen = Rc::new(Cell::new(read_failed)); let mut app = LightcraftApp::new(session, services(originals.clone(), backend.clone(), files.clone(), frozen.clone(), cc.egui_ctx.clone())) .with_chinese_font(chinese_font); let ui_written = files.get("ui.json").unwrap_or_default(); @@ -667,11 +693,40 @@ impl eframe::App for WebApp { } } +/// "Auto" follows the browser's languages (issue #2085): the first of `navigator.languages` (else +/// `navigator.language`) that LightCraft ships becomes the UI's default. Every step is fallible +/// (as in [`crate::safety::acquire_tab_lock`]): without a usable navigator Auto stays English, +/// as before. +fn install_browser_language() { + let Ok(navigator) = js_sys::Reflect::get(&js_sys::global(), &"navigator".into()) else { return }; + let mut tags: Vec = Vec::new(); + if let Ok(languages) = js_sys::Reflect::get(&navigator, &"languages".into()) + && let Some(languages) = languages.dyn_ref::() + { + for tag in languages.iter() { + if let Some(tag) = tag.as_string() { + tags.push(tag); + } + } + } + if tags.is_empty() + && let Ok(language) = js_sys::Reflect::get(&navigator, &"language".into()) + && let Some(language) = language.as_string() + { + tags.push(language); + } + if let Some(language) = lightcraft_ui_egui::i18n::locale_from_tags(&tags) { + lightcraft_ui_egui::i18n::set_default_language(language); + log::info!("lightcraft: default language {language:?} from the browser's languages"); + } +} + /// Start the app with the separately fetched Simplified Chinese font (empty in font-free builds). #[wasm_bindgen] pub fn start(chinese_font: Vec) { eframe::WebLogger::init(log::LevelFilter::Info).ok(); safety::install_panic_hook(); + install_browser_language(); log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now()); if chinese_font.is_empty() { log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes"); diff --git a/contributors/people.toml b/contributors/people.toml index 08d002e8b..6f0bd6085 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -57,3 +57,7 @@ sync_github_name = false [people.egegl] real_name = "Ege Gursel" sync_github_name = true + +[people.mahmatias] +display_name = "Márcio Matias de Andrade" +sync_github_name = true diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 4d16c2f4d..ca463dc55 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -233,6 +233,15 @@ mod tests { assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00")); } + #[test] + fn impossible_times_are_rejected_not_rolled_over() { + for bad in ["2026-04-01T10:75:00", "2026-04-01 10:00:75", "2026-04-01T10:60", "2026-04-01T24:00:00", "2026-04-01T10:59:60"] { + assert_eq!(normalize_iso(bad), None, "{bad}"); + assert_eq!(shift_iso(bad, 5), None, "{bad}"); + } + assert_eq!(normalize_iso("2026-04-01T23:59:59").as_deref(), Some("2026-04-01T23:59:59")); + } + #[test] fn runs_by_day_month_year_keep_stacks_together() { let mut c = Catalog::new(); diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index eb7e89ef4..6542bfe18 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -296,6 +296,9 @@ pub fn iso_seconds(s: &str) -> Option { } let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) }; + if hh >= 24 || mm >= 60 || ss >= 60 { + return None; + } // days from civil (proleptic Gregorian) let y2 = if m <= 2 { y - 1 } else { y }; let era = y2.div_euclid(400); diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs index 2f3dc4912..553eb241f 100644 --- a/crates/catalog/src/store.rs +++ b/crates/catalog/src/store.rs @@ -242,7 +242,7 @@ impl Store for MemStore { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests_staging { use super::*; diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 836aed548..2cdf67381 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -99,9 +99,10 @@ pub(crate) fn check_size(format: Format, w: u64, h: u64, opts: &DecodeOptions) - } pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u32), opts: &DecodeOptions) -> Result { - let n = raw.width * raw.height; let ch = raw.model.channels() + raw.alpha as usize; - if n == 0 || raw.buf.len() < n * ch { + let buf_len = + crate::checked_sample_buf_len(raw.width, raw.height, ch).ok_or_else(|| Error::Malformed(format, "sample buffer too short".into()))?; + if (raw.buf.len() as u64) < buf_len { return Err(Error::Malformed(format, "sample buffer too short".into())); } let float = matches!(raw.buf, Buf::F32(_)); @@ -141,7 +142,9 @@ pub(crate) fn finish(format: Format, raw: Raw, meta: Meta, source_dims: (u32, u3 let (image, alpha) = match opts.max_size { Some((mw, mh)) if (image.width as u32 > mw || image.height as u32 > mh) && mw > 0 && mh > 0 => { let factor = (image.width as f64 / mw as f64).max(image.height as f64 / mh as f64); - let f = if factor >= 2.0 { Filter::Box } else { Filter::Mitchell }; + // (Lanczos at 2:1 and more as well: a box average there halves the contrast of + // detail near the output's resolution limit and aliases what is finer) + let f = if factor >= 2.0 { Filter::Lanczos3 } else { Filter::Mitchell }; let img = fit(&image, mw as usize, mh as usize, f); let alpha = alpha.map(|a| fit(&a, mw as usize, mh as usize, f)); (img, alpha) diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index d14b1812f..a20a47d14 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -338,3 +338,29 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result Option { + (width as u64).checked_mul(height as u64).and_then(|n| n.checked_mul(ch as u64)).filter(|&n| n > 0) +} + +#[cfg(test)] +mod checked_arithmetic_tests { + use super::*; + + #[test] + fn checked_sample_buf_len_wrap_inputs_rejected() { + assert_eq!(checked_sample_buf_len(usize::MAX, usize::MAX, 4), None); + assert_eq!(checked_sample_buf_len(0, 1, 3), None); + } + + #[test] + fn checked_sample_buf_len_normal_accepted() { + assert_eq!(checked_sample_buf_len(100, 1, 3), Some(300)); + assert_eq!(checked_sample_buf_len(1, 1, 1), Some(1)); + // 300 Mpx RGBA is within the default pixel limit, so it must not be refused by sample count. + assert_eq!(checked_sample_buf_len(20_000, 15_000, 4), Some(1_200_000_000)); + } +} diff --git a/crates/engine/build.rs b/crates/engine/build.rs index 056c7adc8..7f5aa7777 100644 --- a/crates/engine/build.rs +++ b/crates/engine/build.rs @@ -3,7 +3,7 @@ //! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this //! script only reads a local directory (no network). //! -//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the +//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB) and Noto Sans Georgian, to keep the //! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export //! watermark falls back to it there. Native builds embed every font. use std::fmt::Write as _; @@ -78,7 +78,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result { has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular"); has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans"); has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai"); - if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) { + if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai" | "Noto Sans Georgian", "Regular")) { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 614c58c60..e56e80305 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -34,7 +34,8 @@ use lightcraft_raw::{ /// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. /// 8: the bundled NIKON Z 6_2 profile. /// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. -pub const LOOK_VERSION: u32 = 9; +/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews. +pub const LOOK_VERSION: u32 = 10; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -107,14 +108,13 @@ fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bo } /// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no -/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). +/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an +/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on +/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables +/// to its proxy, so it fits only the tone around them. pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { let p = &raw.color.profile; - raw.format == RawFormat::Dng - && !t.matrix_is_fallback - && identity_tone_curve(p.tone_curve.as_ref()) - && p.look_table.is_none() - && p.gain_table_map.is_none() + raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none() } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white @@ -424,9 +424,9 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; /// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model /// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given -/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near -/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than -/// the plain render ([`clips_more`]). +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a +/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not +/// clip more highlights than the plain render ([`clips_more`]). fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. let refuse = |why: &str| { @@ -500,8 +500,9 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb } /// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a -/// small thumbnail is not fitted. -const MIN_PREVIEW_FRACTION: f64 = 0.9; +/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview +/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty. +const MIN_PREVIEW_FRACTION: f64 = 0.1; /// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark /// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. @@ -3959,7 +3960,7 @@ mod tests { } /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the - /// identity, no look table; and no other raw format. + /// identity, with or without a look table; and no other raw format. #[test] fn dng_tone_gates_follow_the_files_own_tags() { use lightcraft_raw::profile::{HsvTable, ToneCurve}; @@ -3981,6 +3982,15 @@ mod tests { assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); let mut looked = color; looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); - assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering"); + assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone"); + } + + /// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not. + #[test] + fn dng_tone_fit_takes_lightroom_sized_previews() { + let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0); + assert!(1024.0 >= floor(7008.0)); + assert!(256.0 < floor(7008.0)); + assert!(1024.0 >= floor(9504.0), "a 61 MP raw too"); } } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index eae955cce..0871e4352 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -313,30 +313,15 @@ pub(crate) fn reload_ops( } /// Re-read photos whose files changed on disk (an external editor saved them): new size, -/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} +/// dimensions and content hash, cached sources dropped. The app runs the same steps +/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { - let ids = s.targets(p)?; - let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids - .iter() - .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) { - Some(Source::File { path }) => Some((*id, path)), - _ => None, - }) - .collect(); - let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::>()); - let mut ops = Vec::new(); - let mut reloaded = Vec::new(); - for ((id, _), info) in paths.into_iter().zip(probed) { - let Ok(info) = info else { continue }; - reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded); - } - for id in &reloaded { - s.media.forget(*id); - } - if !ops.is_empty() { - s.commit("Reload Changed Files", Op::Batch { ops })?; - } - Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()})) + use crate::cmd::reload as r; + let items = r::plan(s, p)?; + let probe = r::file_probe(s); + let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {}); + let reloaded = r::apply(s, items, results)?; + Ok(r::to_json(&reloaded)) } /// Duplicate: copy the file next to itself (`-copy`, never overwriting) and add it with the @@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec { "Reload from Disk", ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}", has_selection, reload ), diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index e3d59c567..ce7bf4a65 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> { Ok((a as u32, b as u32)) } -fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) { +/// How a new aspect ratio is turned (issue #2253). +#[derive(Clone, Copy, PartialEq)] +enum Turn { + /// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop + /// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image. + LikeCrop, + /// As given: a custom `[w, h]` taller than wide is a portrait crop. + AsGiven, +} + +fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) { + let r = d.crop.geometry.rect_px(w, h); + let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h }; + let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) }); d.crop.aspect = aspect; - let a = aspect.map(|(aw, ah)| { - // orientation of the crop follows the image (landscape image → landscape crop) - let (aw, ah) = (aw as f64, ah as f64); - if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw } - }); + let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64); let angle = d.crop.geometry.angle; d.crop.geometry = crop_fit_angle(w, h, angle, a); } @@ -628,11 +637,19 @@ pub fn specs() -> Vec { "Crop Aspect", [], None, - "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}", + "{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either", has_active, |s, p| { let id = active(s, "crop.aspect")?; let (w, h) = crop_dims(s, id); + // checked first, whatever the aspect: a bad value is an error on every path + let turn = match p.get("orientation") { + Some(Value::String(o)) if o == "crop" => Turn::LikeCrop, + Some(Value::String(o)) if o == "given" => Turn::AsGiven, + Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")), + None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven, + None => Turn::LikeCrop, + }; let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some()); let lock_current = match p.get("aspect").and_then(Value::as_str) { Some("current") => true, @@ -667,7 +684,7 @@ pub fn specs() -> Vec { if aspect.is_none() { d.crop.aspect = None; } else { - set_aspect(d, w, h, aspect); + set_aspect(d, w, h, aspect, turn); } Ok(()) }) @@ -1103,11 +1120,8 @@ pub fn specs() -> Vec { |s, p| { let c = "history.restore"; let id = active(s, c)?; - let i = f64_req(p, "index", c)?; - if !(i.is_finite() && i >= 0.0) { - return Err(bad(c, "no such step")); - } - go_to_history_step(s, c, id, i as usize) + let i = index_req(p, "index", c)?; + go_to_history_step(s, c, id, i) } ), cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| { diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index e93d04175..949c08f1b 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -17,6 +17,12 @@ impl Session { /// `app.export` params with a named `preset` expanded: the preset's params, overridden by the /// call's own (`preset` itself removed). Unknown preset names are an error. pub fn export_params(&self, p: &Value) -> std::result::Result { + self.export_params_or(p, crate::export::ExportFormat::default()) + } + + /// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format + /// (see [`crate::export::ExportOptions::export_format`]). + pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result { let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) }; let (preset, _) = self .all_export_presets() @@ -24,12 +30,17 @@ impl Session { .find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim())) .ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?; // a preset saved by another version may hold keys this one doesn't know: not the caller's typo - let mut out = crate::export::ExportOptions::known_keys_only(&preset.params); + let mut out = crate::export::ExportOptions::known_keys(&preset.params); if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) { for (k, v) in own.iter().filter(|(k, _)| *k != "preset") { o.insert(k.clone(), v.clone()); } } + // the preset's depth is judged against the format written, known only now (`path`); a + // depth the call passes itself is the caller's, and validated as such + if !p.get("bitDepth").is_some_and(|d| !d.is_null()) { + crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback); + } Ok(out) } } @@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result { let mut params = match p.get("params") { Some(v @ Value::Object(_)) => v.clone(), Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")), - None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?, + // a stored setting, not the caller's typo: a depth it holds that its `format` lacks was + // written before #1952 and is dropped, as Export with Previous does; without `format` + // it is judged when the preset exports + None => { + let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?; + let mut last = crate::export::ExportOptions::known_keys(last); + if last.get("format").is_some_and(Value::is_string) { + crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default()); + } + last + } }; if let Some(o) = params.as_object_mut() { o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str())); } - // a preset that app.export would refuse is refused here, where the typo is on screen - crate::export::ExportOptions::validate(ID, ¶ms)?; + // a preset that app.export would refuse is refused here, where the typo is on screen; without + // `format`, its `bitDepth` is judged when it exports, against the format written then + let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(¶ms, Default::default())); + crate::export::ExportOptions::validate_as(ID, ¶ms, format)?; let preset = ExportPreset { name: name.to_string(), params }; match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) { Some(x) => *x = preset, diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index 085183d58..c9ffb6b7e 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -115,6 +115,12 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, fn reverse(s: &mut Session, p: &Value) -> Result { const C: &str = "metadata.reverseGeocode"; let overwrite = bool_or(p, "overwrite", false); + // the place list holds towns of 15 000 people or more: a photo in a smaller one finds the nearest + // larger town, which may be another municipality; past this many metres City is left empty (#2242) + let max_distance = match p.get("maxDistance") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_f64().filter(|m| m.is_finite() && *m > 0.0).ok_or_else(|| bad(C, "`maxDistance` must be a positive number of metres"))?), + }; let ids = s.targets(p)?; let todo: Vec<(PhotoId, LatLon)> = ids .iter() @@ -140,14 +146,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result { } t.progress(n as u64, todo.len() as u64); } - let Some(place) = names.reverse(*at) else { continue }; + let Some(mut place) = names.reverse(*at) else { continue }; + // too far from the town found: State and Country still hold, City (and below) doesn't + let too_far = max_distance.is_some_and(|m| place.distance > m); + let nearest_city = too_far.then(|| std::mem::take(&mut place.city)); + if too_far { + place.sublocation.clear(); + } let Some(ph) = s.catalog.photo(*id) else { continue }; let mut meta = ph.meta.clone(); fill(&mut meta, &place, overwrite); if meta != ph.meta { ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) }); } - found.push(json!({"id": id.0, "place": place})); + let mut entry = json!({"id": id.0, "place": place}); + if let Some(city) = nearest_city { + entry["nearestCity"] = json!(city); + } + found.push(entry); } drop(task); let changed = ops.len(); @@ -271,7 +287,7 @@ pub fn specs() -> Vec { "Fill Location from GPS", ["Photo"], None, - "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step", + "{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder, maxDistance?: metres (past this distance from the town found, City is left empty and the town is given as nearestCity)} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}, nearestCity?}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step. The place names list towns of 15 000 people or more (GeoNames cities15000): a photo taken in a smaller town gets the nearest larger one, up to 50 km away (`distance` says how far); `maxDistance` keeps such a town out of City", has_selection, reverse ), @@ -343,6 +359,36 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Issue #2242: the place names hold only towns of 15 000 people or more, so a photo in a + /// village finds the nearest larger town. `maxDistance` leaves City empty past that distance + /// (State and Country still fill) and says which town it was. + #[test] + fn max_distance_leaves_a_far_town_out_of_city() { + let mut s = Session::with_demo(); + let ids = s.visible_cloned(); + let dir = dataset("maxdist"); + // about 5 km north of Paris (like Portofino and Rapallo in the issue), and right in Paris + set_gps(&mut s, ids[0], Some((48.90, 2.35)), ""); + set_gps(&mut s, ids[1], Some((48.855, 2.35)), ""); + let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0], "dataset": dir, "maxDistance": 2000})).unwrap(); + let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone(); + assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("", "Île-de-France", "France")); + assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Paris", "close enough"); + let far = r["photos"].as_array().unwrap().iter().find(|p| p["id"] == json!(ids[0].0)).unwrap(); + assert_eq!((far["place"]["city"].as_str(), far["nearestCity"].as_str()), (Some(""), Some("Paris")), "{far}"); + assert!(far["place"]["distance"].as_f64().unwrap() > 2000.0); + // without it, the nearest town fills City as before + s.undo_step().unwrap(); + s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap(); + assert_eq!(s.catalog.photo(ids[0]).unwrap().meta.city, "Paris"); + // hostile values are refused, not taken as "no limit" + for bad in [json!(-1), json!(0), json!("far"), json!(true)] { + let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir, "maxDistance": bad})).unwrap_err(); + assert!(e.to_string().contains("maxDistance"), "{e}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn explains_missing_data_and_gps() { let mut s = Session::with_demo(); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 860cf089f..5567e0abf 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1488,7 +1488,7 @@ pub fn specs() -> Vec { always, |_, p| { let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?; let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?; - Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings})) + Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::>(),"warnings":data.warnings,"skipped":data.skipped})) }), cmd!( "library.importLightroom", diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs index 387909a6d..d791b4260 100644 --- a/crates/engine/src/cmd/mask_effects.rs +++ b/crates/engine/src/cmd/mask_effects.rs @@ -287,12 +287,7 @@ fn effect_index(s: &Session, name: &str, c: &str) -> Result { /// The selected mask's adjustments on the active photo. fn selected_mask(s: &Session, p: &Value, c: &str) -> Result { let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; - let mid = p - .get("id") - .and_then(Value::as_u64) - .and_then(|v| u32::try_from(v).ok()) - .or(s.active_mask) - .ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; + let mid = super::masks::mask_id(p, s.active_mask, c)?; let d = s.develop_of(id).unwrap_or_default(); d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}"))) } @@ -341,10 +336,12 @@ fn apply_effect(s: &mut Session, p: &Value) -> Result { // the masks: `ids`, `all`, else `id` or the selected one let ids: Vec = if p.get("all").and_then(Value::as_bool).unwrap_or(false) { d.masks.iter().map(|m| m.id).collect() - } else if let Some(a) = p.get("ids").and_then(Value::as_array) { - a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect() } else { - vec![selected_mask(s, p, C)?.id] + match p.get("ids") { + None | Some(Value::Null) => vec![selected_mask(s, p, C)?.id], + Some(Value::Array(a)) => a.iter().map(|v| super::masks::parse_mask_id(v, C)).collect::>()?, + Some(_) => return Err(bad(C, "`ids` must be a list of mask IDs")), + } }; if ids.is_empty() { return Err(bad(C, "no masks to apply the effect to")); diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 0efb3b658..dc403df9b 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -199,8 +199,28 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec
, c: &str) -> Result { - p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)")) +pub(super) fn parse_mask_id(v: &Value, c: &str) -> Result { + v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(c, "`id` must be a non-negative integer that fits in u32")) +} + +/// Omitted or null targets keep the command's default; malformed explicit targets never do. +fn optional_mask_id(p: &Value, c: &str) -> Result> { + p.get("id").filter(|v| !v.is_null()).map(|v| parse_mask_id(v, c)).transpose() +} + +pub(super) fn mask_id(p: &Value, active: Option, c: &str) -> Result { + optional_mask_id(p, c)?.or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)")) +} + +fn optional_index(p: &Value, key: &str, c: &str) -> Result> { + p.get(key) + .filter(|v| !v.is_null()) + .map(|v| { + v.as_u64() + .and_then(|v| usize::try_from(v).ok()) + .ok_or_else(|| bad(c, format!("`{key}` must be a non-negative integer that fits in usize"))) + }) + .transpose() } fn find(masks: &mut [Mask], id: u32, c: &str) -> Result { @@ -307,7 +327,7 @@ pub fn specs() -> Vec { let connect = bool_or(p, "connect", false); let mut connected = false; let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); - let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask); + let want = optional_mask_id(p, "mask.brushStroke")?.or(s.active_mask); let adjust = super::mask_effects::new_mask_adjust(s, "brush"); let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) { @@ -363,7 +383,7 @@ pub fn specs() -> Vec { cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| { let shape: MaskShape = serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?; - let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0) as usize; + let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0); let mid = mask_id(p, s.active_mask, "mask.update")?; masks_edit(s, "mask.update", "Edit Mask", |masks, _| { let i = find(masks, mid, "mask.update")?; @@ -381,6 +401,8 @@ pub fn specs() -> Vec { has_active, |s, p| { let c = "mask.sampleColor"; + let mid = mask_id(p, s.active_mask, c)?; + let comp = optional_index(p, "component", c)?; let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?); let add = p.get("add").and_then(Value::as_bool).unwrap_or(false); let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; @@ -390,8 +412,6 @@ pub fn specs() -> Vec { let req = lightcraft_pipeline::RenderRequest::fit(384, 384); let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y)) .ok_or_else(|| bad(c, "the point is outside the photo"))?; - let mid = mask_id(p, s.active_mask, c)?; - let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize); let mut out = Vec::new(); masks_edit(s, c, "Color Range", |masks, _| { let i = find(masks, mid, c)?; @@ -482,8 +502,8 @@ pub fn specs() -> Vec { let mid = mask_id(p, s.active_mask, c)?; let d = s.develop_of(id).unwrap_or_default(); let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?; - let k = match p.get("component").and_then(Value::as_u64) { - Some(k) => k as usize, + let k = match optional_index(p, "component", c)? { + Some(k) => k, None => m .components .iter() @@ -604,7 +624,7 @@ pub fn specs() -> Vec { } ), cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| { - s.active_mask = p.get("id").and_then(Value::as_u64).map(|v| v as u32); + s.active_mask = optional_mask_id(p, "mask.select")?; ok() }), cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| { @@ -684,7 +704,7 @@ pub fn specs() -> Vec { |s, p| { let c = "mask.component"; let mid = mask_id(p, s.active_mask, c)?; - let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize; + let k = optional_index(p, "component", c)?.ok_or_else(|| bad(c, "missing component"))?; let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string(); let op: Option = match p.get("op") { Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?), @@ -879,8 +899,8 @@ pub fn specs() -> Vec { ), cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| { let n = spots_len(s); - s.active_spot = match p.get("index").and_then(Value::as_u64) { - Some(i) if (i as usize) < n => Some(i as usize), + s.active_spot = match optional_index(p, "index", "spot.select")? { + Some(i) if i < n => Some(i), Some(_) => return Err(bad("spot.select", "no such spot")), None => None, }; @@ -1040,7 +1060,7 @@ fn spots_len(s: &Session) -> usize { /// `index` from the params, else the selected spot. fn spot_index(s: &Session, p: &Value, c: &str) -> Result { - let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?; + let i = optional_index(p, "index", c)?.or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?; if i >= spots_len(s) { return Err(bad(c, "no such spot")); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4b86d4012..22af48f44 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -38,6 +38,7 @@ pub mod previews; mod print; mod query; pub mod recheck; +pub mod reload; mod slideshow; mod sync; mod xmp; @@ -198,12 +199,18 @@ pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) } -/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize` -/// saturates to 0), just as `history.restore` already refuses a negative step index. +/// A non-negative whole number that fits `usize`. Keep integer JSON exact; accept integral +/// floating-point values for compatibility, without truncating or saturating their conversion. pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result { + let invalid = || bad(cmd, format!("`{key}` must be a non-negative integer")); + if let Some(i) = p.get(key).and_then(Value::as_u64) { + return usize::try_from(i).map_err(|_| invalid()); + } let i = f64_req(p, key, cmd)?; - if !(i.is_finite() && i >= 0.0) { - return Err(bad(cmd, format!("`{key}` must be a non-negative integer"))); + // The exclusive bound is exactly representable, unlike usize::MAX on 64-bit targets. + let upper_bound = 2.0_f64.powi(usize::BITS as i32); + if !(i.is_finite() && i >= 0.0 && i.fract() == 0.0 && i < upper_bound) { + return Err(invalid()); } Ok(i as usize) } @@ -223,3 +230,29 @@ pub(crate) fn point(p: &Value, key: &str) -> Option { let a = p.get(key)?.as_array()?; Some(lightcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::index_req; + + #[test] + fn command_indices_preserve_integer_precision_and_check_usize_bounds() { + for i in [0, 1, usize::MAX] { + assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i); + } + #[cfg(target_pointer_width = "64")] + { + let i = 9_007_199_254_740_993usize; + assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i, "integer JSON must not round through f64"); + } + let upper_bound = 2.0_f64.powi(usize::BITS as i32); + assert!(index_req(&json!({"index": upper_bound}), "index", "test").is_err(), "2^usize::BITS must not saturate"); + #[cfg(target_pointer_width = "32")] + { + assert!(index_req(&json!({"index": u64::from(u32::MAX) + 1}), "index", "test").is_err()); + assert_eq!(index_req(&json!({"index": f64::from(u32::MAX)}), "index", "test").unwrap(), usize::MAX); + } + } +} diff --git a/crates/engine/src/cmd/reload.rs b/crates/engine/src/cmd/reload.rs new file mode 100644 index 000000000..0c1cb964c --- /dev/null +++ b/crates/engine/src/cmd/reload.rs @@ -0,0 +1,114 @@ +//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a +//! raw re-decode takes a second or more per file, and a selection of them froze the window while +//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads +//! their files without the session (cancellable, with progress), and [`apply`] commits what came +//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once. +//! +//! Reloads can overlap (the user picks Reload from Disk again while a first one still reads), so +//! each [`plan`] takes a ticket and [`apply`] drops a photo's result when a reload planned later +//! has already been applied to it: an older read never replaces a newer one. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::media::{FileProbe, ProbeInfo}; +use crate::{Result, Session}; + +/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile +/// is skipped by [`apply`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub id: PhotoId, + pub path: String, + /// The [`plan`] it came from, in the order reloads were started (see [`Tickets`]). + pub ticket: u64, +} + +/// Which reload each photo last took its file information from, so a reload started earlier but +/// finishing later doesn't put back what its file was before (session memory, not saved). +#[derive(Debug, Default)] +pub struct Tickets { + last: u64, + applied: std::collections::BTreeMap, +} + +/// The photos `p` targets (`ids`, else the selection) that have a file, under a new ticket. No +/// file-system calls. +pub fn plan(s: &mut Session, p: &Value) -> Result> { + let ids = s.targets(p)?; + s.reload_tickets.last = s.reload_tickets.last.saturating_add(1); + let ticket = s.reload_tickets.last; + Ok(ids + .into_iter() + .filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) { + Some(Source::File { path }) => Some(Item { id, path: path.clone(), ticket }), + _ => None, + }) + .collect()) +} + +/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file +/// access, where a reload reads nothing but the file name, as the command does). +pub fn file_probe(s: &Session) -> Option { + s.media.file_probe.clone() +} + +/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session. +/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further +/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]). +pub fn probe( + probe: Option<&FileProbe>, + items: &[Item], + cancel: &AtomicBool, + progress: &dyn Fn(usize), +) -> Vec> { + let paths: Vec = items.iter().map(|i| i.path.clone()).collect(); + let done = AtomicUsize::new(0); + #[cfg(not(target_arch = "wasm32"))] + let r = std::thread::scope(|sc| { + let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel)); + while !h.is_finished() { + progress(done.load(Ordering::Relaxed)); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + // a probe that panicked (already logged): nothing is known about any file + h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect()) + }); + #[cfg(target_arch = "wasm32")] + let r = crate::import::probe_all(probe, &paths, &done, cancel); + progress(done.load(Ordering::Relaxed)); + r +} + +/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual +/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed +/// or relinked since it was listed, or already given a newer reload's result, is skipped. Returns +/// the photos that changed. +pub fn apply(s: &mut Session, items: Vec, results: Vec>) -> Result> { + let mut ops = Vec::new(); + let mut reloaded = Vec::new(); + for (item, info) in items.into_iter().zip(results) { + let Ok(info) = info else { continue }; + let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path)); + let superseded = s.reload_tickets.applied.get(&item.id).is_some_and(|t| *t > item.ticket); + if !current || superseded { + continue; + } + s.reload_tickets.applied.insert(item.id, item.ticket); + crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded); + } + for id in &reloaded { + s.media.forget(*id); + } + if !ops.is_empty() { + s.commit("Reload Changed Files", Op::Batch { ops })?; + } + Ok(reloaded) +} + +/// `{reloaded: [ids]}`. +pub fn to_json(reloaded: &[PhotoId]) -> Value { + json!({"reloaded": reloaded.iter().map(|i| i.0).collect::>()}) +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index e9d1cbc58..a9e3dd21b 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -816,8 +816,17 @@ impl ExportOptions { /// newer version may have written and this one doesn't know dropped, also inside `watermark`. /// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are /// not the caller's typo, and refusing them would make a preset or Export with Previous fail - /// for good after an update. + /// for good after an update. A `bitDepth` the stored format doesn't have (written at the + /// format's own depth before issue #1952) is dropped too. pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value { + let mut p = Self::known_keys(p); + Self::drop_unfit_depth(&mut p, ExportFormat::default()); + p + } + + /// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be + /// known once the call's own params (`path`) are merged in. + pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value { let mut p = p.clone(); if let Some(o) = p.as_object_mut() { o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str())); @@ -828,13 +837,44 @@ impl ExportOptions { p } + /// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with + /// `fallback`) doesn't have: it was written at the format's own depth before issue #1952. + pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) { + let format = Self::export_format(p, fallback); + if let Some(o) = p.as_object_mut() + && let Some(depth) = o.get("bitDepth").and_then(whole) + && !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth) + { + o.remove("bitDepth"); + } + } + /// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an /// unknown parameter or a value that cannot be read as what it is for is an error, never a /// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`) /// are accepted and left to the caller. pub fn from_params(p: &serde_json::Value) -> crate::Result { - Self::validate("app.export", p)?; - Ok(Self::from_json(p)) + Self::from_params_or(p, ExportFormat::default()) + } + + /// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no + /// format; the format returned is [`ExportOptions::export_format`], the one written. + pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result { + let format = Self::export_format(p, fallback); + Self::validate_as("app.export", p, Some(format))?; + Ok(Self { format, ..Self::from_json(p) }) + } + + /// The format `app.export` writes: `format` (`null`: the default), else the one the extension of + /// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there, + /// the headless backend PNG), else the default. + pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat { + use serde_json::Value; + match (p.get("format"), p.get("path").and_then(Value::as_str)) { + (Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(), + (None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback), + (None, None) => ExportFormat::default(), + } } /// Check `app.export` params for `cmd` (the command named in the error): every key must be an @@ -842,6 +882,12 @@ impl ExportOptions { /// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`] /// with its sizes in range (issue #183). `null` means "not given", as a dropped key does. pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> { + Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default()))) + } + + /// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported + /// (`None`: not known yet, as for a preset without `format`). + pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option) -> crate::Result<()> { use serde_json::Value; let bad = |msg: String| crate::cmd::bad(cmd, msg); let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) }; @@ -949,6 +995,18 @@ impl ExportOptions { _ => return Err(bad(unknown_key("parameter", k, &known))), } } + // issue #1952: a depth the format doesn't have would be written at the format's own depth + if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) { + let mut depths: Vec = Self::bit_depths(format).iter().map(|d| d.0).collect(); + if !depths.iter().any(|d| i64::from(*d) == depth) { + depths.sort_unstable(); + let mut list: Vec = depths.iter().map(u8::to_string).collect(); + let last = list.pop().unwrap_or_default(); + let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) }; + let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default(); + return Err(bad(format!("`bitDepth` must be {list} for {name}"))); + } + } Ok(()) } @@ -2021,6 +2079,9 @@ fn run_batch_with( ahead: lanes, }; std::thread::scope(|sc| { + // stops the lanes however this thread leaves the scope: a panic in `progress` or the placer + // would otherwise leave them waiting and the scope's join would never return + let _stop = StopOnDrop(&queue); for k in 1..lanes { let q = &queue; // a lane that can't start leaves its photos to the others (and to this thread) @@ -2038,13 +2099,20 @@ fn run_batch_with( } Ok::<_, String>(()) }; - let r = placed(); - queue.stop(); - r + placed() })?; Ok(std::mem::take(&mut placer.out)) } +/// Stops a [`Queue`] when dropped, also while unwinding from a panic. +struct StopOnDrop<'a>(&'a Queue); + +impl Drop for StopOnDrop<'_> { + fn drop(&mut self) { + self.0.stop(); + } +} + /// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic /// becomes that photo's error, not the batch's end. fn run_gated(item: PreparedExport) -> Result { @@ -3212,6 +3280,7 @@ mod tests { resize: Some(Resize::default()), limit_kb: Some(500), watermark: Some(Watermark { text: "©".into(), ..Default::default() }), + format: ExportFormat::Png, bit_depth: Some(16), ..Default::default() }; @@ -3248,6 +3317,51 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers + /// it), not written at the format's own depth; the error names the depths the format takes. + #[test] + fn bit_depth_must_suit_the_format() { + use serde_json::json; + let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg")); + assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png")); + assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff")); + // no `format`: the one `path` names, else the default (JPEG) + assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png")); + assert!(err(json!({"bitDepth": 16})).contains("for jpeg")); + assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok()); + // a path naming no format, or a `null` format, exports the default too + assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg")); + assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg")); + // or the caller's own format for such a path, which is the one returned + let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png); + assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png")); + assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png); + assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff); + // stored settings (a preset, Export with Previous) lose such a depth instead of failing + let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(stored.get("bitDepth"), None); + assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32); + for f in [ + ExportFormat::Jpeg, + ExportFormat::Png, + ExportFormat::Tiff, + ExportFormat::Psd, + ExportFormat::Webp, + ExportFormat::Avif, + ExportFormat::Jxl, + ExportFormat::Original, + ExportFormat::Dng, + ExportFormat::RenderedDng, + ] { + for &(b, _) in ExportOptions::bit_depths(f) { + let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() }; + assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}"); + } + } + } + /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own /// folder; it is checked like the other params and kept in presets and Export with Previous. #[test] @@ -3474,6 +3588,32 @@ mod tests { assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written"); } + // A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it + #[test] + fn a_panic_in_progress_does_not_hang_the_lanes() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + let o = ExportOptions::from_json(&json!({"format": "png", "width": 40})); + let items = prepare_batch(&mut s, &ids, &o).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let to = Destination { dir: "out".into(), exact: None }; + let mut write = |_: &str, _: &[u8]| Ok(()); + let mut calls = 0; + let mut progress = |_: usize, _: &str| { + calls += 1; + assert!(calls < 2, "progress panics on its second call"); + true + }; + let r = crate::guard::catch("test batch", || { + run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2) + }); + tx.send(r.and_then(|r| r)).ok(); + }); + let r = rx.recv_timeout(std::time::Duration::from_secs(60)); + assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}"); + } + // A photo that fails is reported in its place while the others are exported; with // stop_on_error the batch ends there and nothing after it is written #[test] diff --git a/crates/engine/src/face_download.rs b/crates/engine/src/face_download.rs index df79d5630..f5d0dd5fc 100644 --- a/crates/engine/src/face_download.rs +++ b/crates/engine/src/face_download.rs @@ -181,9 +181,16 @@ impl Drop for Downloads { for job in self.jobs.values() { job.cancel.store(true, Ordering::Relaxed); } - let started = std::time::Instant::now(); - while self.running() && started.elapsed() < Duration::from_secs(1) { - std::thread::sleep(Duration::from_millis(10)); + // Give a running download a moment to notice. Not on the web: there are no download + // threads, and `Instant::now` / `thread::sleep` panic on wasm32 ("time not implemented"), + // which killed the page when a `Session` was replaced after its library failed to open + // (issue #1507). + #[cfg(not(target_arch = "wasm32"))] + { + let started = std::time::Instant::now(); + while self.running() && started.elapsed() < Duration::from_secs(1) { + std::thread::sleep(Duration::from_millis(10)); + } } } } @@ -317,6 +324,18 @@ mod tests { } } + /// Dropping a `Downloads` with nothing running (every session has one) returns at once: the + /// wait for running threads is the only part that takes time, and the web has no threads. + #[test] + fn dropping_idle_downloads_returns_at_once() { + let t = std::time::Instant::now(); + drop(Downloads::default()); + let mut d = Downloads::default(); + d.set_outcome("m", State::Failed("x".into())); + drop(d); + assert!(t.elapsed() < Duration::from_millis(500), "{:?}", t.elapsed()); + } + #[test] fn a_running_download_shows_in_activity_and_cancels() { use crate::activity::{Activity, Unit}; diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index a246c4dca..db9e658d0 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -105,7 +105,7 @@ mod tests { assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces"); // Every script face that ships is a UI fallback, and has the glyphs of its script (#736). use ab_glyph::Font; - for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] { + for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة"), ("Geor", "ფოტო")] { let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue }; let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses"); assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 9aa847b01..7a3bc1f05 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -494,7 +494,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V /// Probe `paths` (several at once), counting each in `done`. Stops before the next file once /// `cancel` is set: the files not reached come back as errors. -fn probe_all( +pub(crate) fn probe_all( probe: Option<&crate::media::FileProbe>, paths: &[String], done: &std::sync::atomic::AtomicUsize, @@ -965,9 +965,11 @@ impl ImportJob { } /// What the library held when the import was set up, by path and by content: made once, with - /// the first files (on the thread that prepares them), from the library as it was then. - fn look_up_library(&mut self) { - let Some((library, fresh)) = self.library.take() else { return }; + /// the first files (on the thread that prepares them), from the library as it was then. The + /// snapshot comes back: the files the import is adding are ranked against it for the ownership + /// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]). + fn look_up_library(&mut self) -> Option { + let (library, fresh) = self.library.take()?; for p in library.photos() { // a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate if p.deleted && fresh { @@ -980,12 +982,14 @@ impl ImportJob { self.by_hash.insert(h.clone(), Some(p.id)); } } + Some(library) } /// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned. pub fn prepare_files(&mut self, files: Vec, cancel: &std::sync::atomic::AtomicBool) -> Prepared { use std::sync::atomic::Ordering::Relaxed; - self.look_up_library(); + // the library as it was, kept for the ownership of the shared stem sidecars below + let library = self.look_up_library(); let opts = self.opts.clone(); let mode = self.mode; let mut out = Prepared { scanned: files.len(), items: Vec::new() }; @@ -1025,6 +1029,13 @@ impl ImportJob { let probed: Vec> = cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect(); crate::memory::release(); + // Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones + // arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar + // and never the raw's packet (issue #1905). + let stems = crate::sidecar::ImportStems::for_import( + library.as_ref(), + todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))), + ); // Files are planned one by one, in order (duplicates, sidecars, names, folders); their // copies or moves — the slow part, reading and writing every byte and verifying it — are // collected and run on several threads (issue #367), then their outcomes take their places. @@ -1063,7 +1074,7 @@ impl ImportJob { // the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files // and names the copy when the file itself has none let raw = info.kind == MediaKind::Raw; - let packet = crate::sidecar::find_sidecar(&path, self.naming) + let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path) .and_then(|f| std::fs::read_to_string(f).ok()) .or_else(|| info.xmp.clone().filter(|_| raw)); let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera); @@ -1566,6 +1577,7 @@ impl Session { display: None, slide: None, preview_cut: None, + display_fit: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 25e6ef52f..ec7c9f7e7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -186,6 +186,10 @@ pub struct Interaction { pub label: String, pub photo: PhotoId, pub original: Arc, + /// Whether an edit during the interaction changed the crop itself. If none did, the crop the + /// photo shows was fitted by Constrain Crop, and the final commit starts again from the original + /// crop, so Auto Sync carries the edit and not the active photo's fitted crop. + pub crop_edited: bool, } /// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. @@ -240,6 +244,8 @@ pub struct Session { /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, + /// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]). + pub(crate) reload_tickets: cmd::reload::Tickets, /// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by /// [`Session::retry_unsaved_sidecars`] once their file is reachable. pub(crate) unsaved_sidecars: std::collections::BTreeSet, @@ -434,6 +440,7 @@ impl Session { pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, + reload_tickets: Default::default(), unsaved_sidecars: Default::default(), denoise: Default::default(), face_models_dir: None, @@ -881,22 +888,58 @@ impl Session { /// undo step; otherwise it is committed with a history entry. pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> { let now = (self.clock)(); - let settings = Arc::new(settings); - if let Some(i) = &self.interaction + // Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990) + let requested = settings; + let settings = Arc::new(self.fit_constrained_crop(id, requested.clone())); + let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry); + if let Some(i) = &mut self.interaction && i.photo == id { + i.crop_edited |= crop_edited; return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) }); } let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?]; - ops.extend(self.auto_sync_ops(id, &settings, label)); + ops.extend(self.auto_sync_ops(id, &requested, label)); let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } }; self.commit(label, op) } - /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the - /// other selected photos: only the settings that changed; never spot removal or red eye (they - /// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor - /// history / snapshot restores. + /// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue + /// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections, + /// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner + /// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as + /// drawn (the render still constrains it). + fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings { + if !d.geometry.constrain_crop { + return d; + } + let Some(photo) = self.catalog.photo(id) else { return d }; + let before = &photo.develop; + let turned_on = !before.geometry.constrain_crop; + let warp_changed = before.geometry != d.geometry + || before.optics != d.optics + || before.orientation != d.orientation + || ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s)); + let crop_edited = before.crop.geometry != d.crop.geometry; + if !(turned_on || (warp_changed && !crop_edited)) { + return d; + } + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + photo.width.max(1) as usize, + photo.height.max(1) as usize, + &d, + true, + photo.embedded_lens.as_ref(), + ); + d.crop.geometry.rect = frame.crop.rect; + d + } + + /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked, + /// before its crop is fitted) over to the other selected photos: only the settings that changed; + /// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process + /// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each + /// photo's own crop: the active photo's fitted crop is never copied over another's. fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec { if !self.auto_sync || self.active() != Some(id) @@ -922,7 +965,7 @@ impl Session { .filter(|x| **x != id) .filter_map(|x| self.develop_of(*x).map(|d| (*x, d))) .filter_map(|(x, d)| { - let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0); + let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0)); (synced != *d).then(|| self.develop_op(x, synced, label)).flatten() }) .collect() @@ -944,7 +987,7 @@ impl Session { } let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?; let original = self.develop_of(id).unwrap_or_default(); - self.interaction = Some(Interaction { label: label.into(), photo: id, original }); + self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false }); Ok(()) } @@ -955,9 +998,15 @@ impl Session { if *cur == *i.original { return Ok(()); } - // Restore the original silently, then commit the final value as one step. + // Restore the original silently, then commit the final value as one step. A crop nobody + // edited goes back to the original's: Constrain Crop fits it again for the final warp, and + // Auto Sync carries the edit, not the active photo's fitted crop (review of #1990). self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?; - self.set_develop(i.photo, (*cur).clone(), &i.label) + let mut last = (*cur).clone(); + if !i.crop_edited { + last.crop.geometry = i.original.crop.geometry; + } + self.set_develop(i.photo, last, &i.label) } pub fn cancel_interaction(&mut self) -> Result<()> { @@ -1249,8 +1298,12 @@ mod tests_albums_local; #[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_constrain_crop; +#[cfg(test)] mod tests_crop_angle; #[cfg(test)] +mod tests_crop_aspect; +#[cfg(test)] mod tests_denoise; #[cfg(test)] mod tests_export; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index e91f0bfff..32ad1da53 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -84,6 +84,9 @@ pub struct Library { /// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes /// exist only in memory ([`Session::unsaved`]). pub unsaved_error: Option, + /// Why the last write of presets.json failed; `None` once it is written. While set, the preset + /// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266). + presets_error: Option, /// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off). retry_at: Option, /// What forgetting untouched Local records did when the library opened. @@ -577,6 +580,7 @@ impl Session { report, last_error: None, unsaved_error: None, + presets_error: None, retry_at: None, forgot_local: None, presets_saved, @@ -651,10 +655,17 @@ impl Session { let Some(lib) = self.library.as_mut() else { return Ok(()) }; if state.hash == lib.presets_saved.hash { lib.presets_saved = state; + lib.presets_error = None; } else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) { - // the state stays stale: the next save tries again + // the state stays stale: retried by the next command and the frame loop (backing off); + // meanwhile the presets count as unsaved, so the UI warns and a backup refuses log::error!("library: presets: {e}"); + lib.presets_error = Some(format!("presets: {e}")); + lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF); } else { + if lib.presets_error.take().is_some() { + log::info!("library: presets written to disk"); + } lib.presets_saved = state; } Ok(()) @@ -664,7 +675,8 @@ impl Session { /// changed outside [`Session::execute`]). pub fn persist_if_dirty(&mut self) { let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t); - if self.library.is_some() && !self.pending_log.is_empty() && !backing_off { + let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some()); + if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off { let _ = self.persist(); } else if let Some(lib) = self.library.as_mut() { poll_compaction(lib); @@ -672,11 +684,14 @@ impl Session { } /// Changes applied in memory but not yet written to disk because saving failed: the number of - /// queued ops and why (`None` when everything is saved, or no library is open). Retried by - /// every command and by [`Session::persist_if_dirty`]. + /// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library + /// is open). Retried by every command and by [`Session::persist_if_dirty`]. pub fn unsaved(&self) -> Option<(usize, &str)> { let lib = self.library.as_ref()?; - lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e)) + let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()); + let presets = lib.presets_error.as_deref(); + let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some()); + ops.or(presets).map(|why| (n, why)) } /// Flush everything and write a snapshot (on quit). Ends an open interaction first. diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index 5133fc80c..9c9979d97 100644 --- a/crates/engine/src/lightroom_archive.rs +++ b/crates/engine/src/lightroom_archive.rs @@ -524,6 +524,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), } } @@ -547,6 +548,7 @@ mod tests { members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])], collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])], warnings: vec!["one warning".into()], + skipped: Vec::new(), } } diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 5f39188a1..797a96708 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -8,7 +8,7 @@ use std::sync::{ atomic::{AtomicBool, AtomicUsize, Ordering}, }; -use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue}; +use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue}; use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source}; use lightcraft_geom::{Affine, Orientation, Point, Rect}; use serde::{Deserialize, Serialize}; @@ -181,6 +181,34 @@ pub struct CatalogImport { pub members: Vec, pub collection_content: Vec, pub warnings: Vec, + /// Catalog images left out because their records are incomplete; reported as data (not text) + /// so each front end can say so in its own language. + #[serde(default)] + pub skipped: Vec, +} + +/// A catalog image that was not imported, and why. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SkippedImage { + /// The image's `Adobe_images.id_local`. + pub image: i64, + pub reason: SkipReason, +} + +/// Why a catalog image was not imported. Lightroom leaves such records behind and ignores them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SkipReason { + /// Its `AgLibraryFile` record is gone. + MissingFile, + /// Its file's `AgLibraryFolder` record is gone. + MissingFolder, + /// Its folder's `AgLibraryRootFolder` record is gone. + MissingRoot, + /// Its file name or root folder path is empty. + IncompletePath, + /// A virtual copy whose master was skipped. + MasterSkipped, } #[derive(Clone, Default, Serialize, Deserialize)] @@ -408,6 +436,9 @@ pub fn read(path: &Path) -> Result { read_with_progress(path, &cancel, &total, &done) } +/// Read a Lightroom Classic catalog for import, counting the tables read in `total` / `done` and +/// stopping when `cancel` is set. Images whose catalog records are incomplete are left out and +/// listed in [`CatalogImport::skipped`] instead of failing the read. pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result { // 13 catalog tables plus one validation stage; ImportJob adds one unit per original path. total.store(14, Ordering::Relaxed); @@ -416,15 +447,15 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, let before = std::fs::metadata(path).map_err(|e| e.to_string())?; let wal_path = PathBuf::from(format!("{}-wal", path.display())); let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); - let main = bytes(path)?; + let main = FileSource::open(path)?; let wal = match std::fs::metadata(&wal_path) { Ok(_) => bytes(&wal_path)?, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(), Err(e) => return Err(e.to_string()), }; - let wal_len = committed_wal_len(&main, &wal)?; + let wal_len = committed_wal_len(main.header(), &wal)?; ensure_read_active(cancel)?; - let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?; + let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?; let after = std::fs::metadata(path).map_err(|e| e.to_string())?; let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after { @@ -522,19 +553,36 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, members: optional_table("AgLibraryCollectionImage", &mut warnings)?, collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?, warnings, + skipped: Vec::new(), }; + // images whose file, folder or root record is gone, or whose path is empty (Lightroom leaves + // these behind and ignores them); they and their virtual copies are skipped and reported + // instead of failing the import + let mut orphans = HashSet::new(); for image in table("Adobe_images", true)? { ensure_read_active(cancel)?; let id = number(&image, "id_local"); - let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?; - let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?; - let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?; + let record = files.get(&number(&image, "rootFile")).ok_or(SkipReason::MissingFile).and_then(|file| { + let folder = folders.get(&number(file, "folder")).ok_or(SkipReason::MissingFolder)?; + let root = roots.get(&number(folder, "rootFolder")).ok_or(SkipReason::MissingRoot)?; + Ok((file, folder, root)) + }); + let (file, folder, root) = match record { + Ok(found) => found, + Err(reason) => { + out.skipped.push(SkippedImage { image: id, reason }); + orphans.insert(id); + continue; + } + }; let root_id = number(root, "id_local"); let root_path = root_paths.get(&root_id).map_or("", String::as_str); let folder_path = text(folder, "pathFromRoot"); let name = text(file, "idx_filename"); if root_path.is_empty() || name.is_empty() { - return Err(format!("image {id}: incomplete original path")); + out.skipped.push(SkippedImage { image: id, reason: SkipReason::IncompletePath }); + orphans.insert(id); + continue; } let (photo_path, original) = original_path(root_path, folder_path, name, path); if let Original::Refused(why) = &original @@ -562,11 +610,36 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, original, }); } + drop_copies_of(&mut out, &orphans); refuse_non_media(&mut out); ensure_read_active(cancel)?; + reader.db.ensure_unchanged()?; + let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok())); + if wal_before != wal_after { + return Err("catalog changed during reading; close Lightroom and retry".into()); + } Ok(out) } +/// Drop the virtual copies whose master was skipped, adding them to the skipped images: a copy has +/// no original of its own. +fn drop_copies_of(data: &mut CatalogImport, masters: &HashSet) { + if masters.is_empty() { + return; + } + let mut skipped = Vec::new(); + data.photos.retain(|p| { + let master = number(&p.image, "masterImage"); + // 0 marks a master, never a copy + if master == 0 || !masters.contains(&master) { + return true; + } + skipped.push(SkippedImage { image: p.source_id, reason: SkipReason::MasterSkipped }); + false + }); + data.skipped.extend(skipped); +} + /// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the /// import's warnings. /// @@ -785,6 +858,8 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } +/// Turn the read catalog into library records in one undo step and return the import report +/// (added, missing, failed, warnings and skipped images). pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result { let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context; // nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135) @@ -1018,7 +1093,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, co } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } @@ -1062,6 +1137,7 @@ mod tests { ], collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), } } /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so @@ -1521,6 +1597,16 @@ mod tests { } /// A one-row-per-table SQLite file: `(name, columns, row)` each. fn native_catalog(tables: &[(&str, &str, Vec)]) -> Vec { + native_catalog_with_last_table_page(tables, None) + } + /// [`native_catalog`] whose last table claims `last_page` as its root page. + fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec)], last_page: Option) -> Vec { + let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect(); + native_catalog_rows(&tables, last_page) + } + /// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last + /// table's root page. + fn native_catalog_rows(tables: &[(&str, &str, Vec>)], last_page: Option) -> Vec { use SqlValue::{Integer as I, Text as T}; let mut bytes = vec![0u8; 5 * 2048]; bytes[..16].copy_from_slice(b"SQLite format 3\0"); @@ -1534,15 +1620,15 @@ mod tests { bytes[44..48].copy_from_slice(&4u32.to_be_bytes()); bytes[56..60].copy_from_slice(&1u32.to_be_bytes()); let mut schema = Vec::new(); - for (i, (name, columns, row)) in tables.iter().enumerate() { + for (i, (name, columns, rows)) in tables.iter().enumerate() { schema.push(vec![ T("table".into()), T((*name).into()), T((*name).into()), - I(i as i64 + 2), + I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }), T(format!("CREATE TABLE {name} ({columns})")), ]); - leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row)); + leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows); } leaf(&mut bytes[..2048], 100, &schema); bytes @@ -1576,11 +1662,104 @@ mod tests { assert_eq!(catalog.photos[0].uuid, "fixture-image"); assert_eq!(number(&catalog.photos[0].image, "rating"), 5); assert!(catalog.photos[0].path.ends_with("test.ARW")); + // Put the required image table beyond the old 1 GiB cutoff without allocating it. + // A main-file-sized allocation would defeat the purpose of the page-backed reader. + // The large fixture relies on Unix sparse-file behavior; small parity runs everywhere. + #[cfg(unix)] + { + let far_offset = 1u64 << 30; + let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64)); + use std::io::{Seek, SeekFrom, Write}; + let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap(); + sparse.write_all(&bytes).unwrap(); + sparse.set_len(far_offset + 2048).unwrap(); + sparse.seek(SeekFrom::Start(far_offset)).unwrap(); + sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap(); + drop(sparse); + let large = read(&path).unwrap(); + assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap()); + let mut session = crate::Session::new(); + session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(session.catalog.len(), 1); + assert_eq!(session.catalog.photos().next().unwrap().rating, 5); + session.execute("edit.undo", &json!({})).unwrap(); + assert!(session.catalog.is_empty()); + } std::fs::write(&path, b"bad catalog").unwrap(); assert!(read(&path).is_err()); std::fs::remove_file(path).unwrap(); } + /// Reads `tables` as a catalog file and returns what the reader made of it. + fn read_tables(tables: &[(&str, &str, Vec>)], name: &str) -> CatalogImport { + let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id())); + std::fs::write(&path, native_catalog_rows(tables, None)).unwrap(); + let catalog = read(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + catalog + } + + /// `tables` with each row as a one-row table, for [`native_catalog_rows`]. + fn one_row(tables: Vec<(&'static str, &'static str, Vec)>) -> Vec<(&'static str, &'static str, Vec>)> { + tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect() + } + + #[test] + fn images_with_incomplete_records_are_skipped_not_fatal() { + use SqlValue::{Integer as I, Text as T}; + // (table, column, value): the record that breaks, and the reason the import gives + let cases = [ + (3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file + (2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder + (1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root + (2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty + ]; + for (table, column, value, reason) in cases { + let mut tables = one_row(photo_tables(("/catalog/", ""))); + tables[table].2[0][column] = value; + let catalog = read_tables(&tables, &format!("skipped-{reason:?}")); + assert!(catalog.photos.is_empty(), "{reason:?}"); + assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]); + assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings); + } + } + + #[test] + fn a_virtual_copy_of_a_skipped_master_is_skipped_too() { + use SqlValue::{Integer as I, Null as N, Text as T}; + let mut tables = one_row(photo_tables(("/catalog/", ""))); + tables[3] = ( + "Adobe_images", + "id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick", + vec![ + // the master's file record is gone, so it is skipped … + vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)], + // … and its virtual copy goes with it, although its own file record is there + vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)], + ], + ); + let catalog = read_tables(&tables, "skipped-copy"); + assert!(catalog.photos.is_empty()); + assert_eq!( + catalog.skipped, + [SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }] + ); + validate(&catalog).unwrap(); + } + + #[test] + fn skipped_images_are_in_the_import_report() { + let mut data = sample(); + data.photos.retain(|p| p.source_id != 1); + drop_copies_of(&mut data, &HashSet::from([1])); + data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile }); + let mut s = crate::Session::new(); + let report = apply(&mut s, data, false).unwrap(); + assert_eq!(report["photos"], 0); + assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}])); + assert_eq!(report["warnings"], json!([])); + } + #[test] fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 52a51b30f..762c8588d 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -355,6 +355,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), } } @@ -399,6 +400,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: Vec::new(), + skipped: Vec::new(), }, normal: Prepared::default(), index: ImportIndex::default(), diff --git a/crates/engine/src/lightroom_sqlite.rs b/crates/engine/src/lightroom_sqlite.rs index 803707c1c..58019f2c1 100644 --- a/crates/engine/src/lightroom_sqlite.rs +++ b/crates/engine/src/lightroom_sqlite.rs @@ -6,7 +6,79 @@ //! than a SQL engine. It never writes its input and has no SQLite runtime //! dependency. +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; +use std::fs::{File, Metadata}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +const MAX_DATABASE_BYTES: u64 = 16 << 30; + +/// A bounded, read-only page source. Only referenced pages are loaded, never the whole file. +pub struct FileSource { + file: Mutex, + path: PathBuf, + before: Metadata, + header: Vec, +} + +impl FileSource { + pub fn open(path: &Path) -> Result { + if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() { + return Err("SQLite catalog must be a regular file".into()); + } + let mut file = File::open(path).map_err(|e| e.to_string())?; + let before = file.metadata().map_err(|e| e.to_string())?; + if !before.is_file() { + return Err("SQLite catalog must be a regular file".into()); + } + if before.len() > MAX_DATABASE_BYTES { + return Err("catalog exceeds 16 GiB import limit".into()); + } + let mut header = vec![0; 100]; + file.read_exact(&mut header).map_err(|e| format!("cannot read SQLite header: {e}"))?; + let source = Self { file: Mutex::new(file), path: path.into(), before, header }; + source.ensure_unchanged()?; + Ok(source) + } + + pub fn header(&self) -> &[u8] { + &self.header + } + + pub fn ensure_unchanged(&self) -> Result<(), String> { + let file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?; + let handle = file.metadata().map_err(|e| e.to_string())?; + let path = std::fs::metadata(&self.path).map_err(|_| "catalog changed during reading; close Lightroom and retry")?; + if !same_metadata(&self.before, &handle) || !same_metadata(&self.before, &path) { + return Err("catalog changed during reading; close Lightroom and retry".into()); + } + Ok(()) + } + + fn page(&self, page_id: u32, size: usize) -> Result, String> { + let start = u64::from(page_id).checked_sub(1).and_then(|n| n.checked_mul(size as u64)).ok_or("page offset overflow")?; + if start.checked_add(size as u64).is_none_or(|end| end > self.before.len()) { + return Err(format!("page {page_id} is truncated")); + } + let mut out = vec![0; size]; + let mut file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?; + file.seek(SeekFrom::Start(start)).and_then(|_| file.read_exact(&mut out)).map_err(|e| format!("cannot read SQLite page {page_id}: {e}"))?; + Ok(out) + } +} + +fn same_metadata(a: &Metadata, b: &Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if a.dev() != b.dev() || a.ino() != b.ino() { + return false; + } + } + a.len() == b.len() && a.modified().ok() == b.modified().ok() && a.created().ok() == b.created().ok() +} const MAX_PAGE_ID: u32 = 16_777_216; const MAX_COLUMNS: usize = 16_384; @@ -44,6 +116,7 @@ pub struct LiveTable { /// Read-only SQLite database image, with a committed WAL overlay if supplied. pub struct Database { main: Vec, + file: Option, overlay: HashMap>, page_size: usize, reserved: usize, @@ -84,17 +157,34 @@ impl Database { /// The WAL argument must include its 32-byte header and only complete /// frames through a commit. Frames are overlaid by page number, with the /// final commit's database-size field defining the visible page count. + #[cfg(test)] pub fn open_with_wal(main: Vec, committed_wal: &[u8]) -> Result { + Self::open(main, None, committed_wal) + } + + pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result { + Self::open(source.header().to_vec(), Some(source), committed_wal) + } + + pub fn ensure_unchanged(&self) -> Result<(), String> { + if let Some(file) = &self.file { + file.ensure_unchanged()?; + } + Ok(()) + } + + fn open(main: Vec, file: Option, committed_wal: &[u8]) -> Result { let main_header = main.get(..100).ok_or("truncated SQLite header")?; if main_header.get(..16) != Some(b"SQLite format 3\0") { return Err("invalid SQLite header signature".into()); } let page_size = sqlite_page_size(be_u16(main_header, 16)?)?; validate_header_fields(main_header, page_size)?; - if main.len() < page_size || !main.len().is_multiple_of(page_size) { + let main_len = file.as_ref().map_or(main.len() as u64, |source| source.before.len()); + if main_len < page_size as u64 || !main_len.is_multiple_of(page_size as u64) { return Err("SQLite image is not a whole number of pages".into()); } - let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?; + let main_pages = u32::try_from(main_len / page_size as u64).map_err(|_| "SQLite image is too large")?; if main_pages == 0 || main_pages > MAX_PAGE_ID { return Err("invalid SQLite image page count".into()); } @@ -140,7 +230,7 @@ impl Database { _ => return Err("unsupported SQLite text encoding".into()), }; - let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() }; + let mut db = Self { main, file, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() }; let schema = db.parse_schema()?; db.tables = schema.tables; db.rowid_aliases = schema.aliases; @@ -166,17 +256,20 @@ impl Database { Ok(state.out) } - fn page(&self, page_id: u32) -> Result<&[u8], String> { + fn page(&self, page_id: u32) -> Result, String> { if page_id == 0 || page_id > self.page_count { return Err(format!("page {page_id} is outside database")); } if let Some(page) = self.overlay.get(&page_id) { - return Ok(page); + return Ok(Cow::Borrowed(page)); + } + if let Some(file) = &self.file { + return file.page(page_id, self.page_size).map(Cow::Owned); } let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?; let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?; let end = start.checked_add(self.page_size).ok_or("page offset overflow")?; - self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated")) + self.main.get(start..end).map(Cow::Borrowed).ok_or_else(|| format!("page {page_id} is truncated")) } fn visit_table(&self, page_id: u32, ncols: usize, alias: Option, depth: usize, state: &mut WalkState) -> Result<(), String> { @@ -219,7 +312,7 @@ impl Database { if state.payload_total > MAX_TABLE_PAYLOAD { return Err("SQLite table payload exceeds bounded import size".into()); } - let payload = self.payload(page, at, payload_len)?; + let payload = self.payload(&page, at, payload_len)?; let mut values = decode_record(&payload, ncols, self.encoding)?; if let Some(index) = alias { if index >= values.len() { @@ -325,7 +418,7 @@ impl Database { out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?); remaining -= take; if remaining > 0 { - next = be_u32_at(overflow, 0)?; + next = be_u32_at(&overflow, 0)?; } } Ok(out) @@ -873,6 +966,72 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result { mod tests { use super::*; + struct Scratch(PathBuf); + impl Scratch { + fn new(bytes: &[u8]) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id())); + std::fs::write(&path, bytes).unwrap(); + Self(path) + } + } + impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + fn file_backed(mut db: Database) -> (Scratch, Database) { + let scratch = Scratch::new(&db.main); + db.file = Some(FileSource::open(&scratch.0).unwrap()); + db.main.truncate(100); + (scratch, db) + } + + #[test] + fn file_source_bounds_and_mutation_detection() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + assert_eq!(source.page(1, 512).unwrap().len(), 512); + assert!(source.page(2, 512).unwrap_err().contains("truncated")); + File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap(); + assert!(source.page(1, 512).is_err()); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + drop(source); + // Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows. + #[cfg(unix)] + { + File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap(); + assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit"); + } + } + + #[cfg(unix)] + #[test] + fn file_source_detects_same_sized_path_replacement() { + let original = Scratch::new(&[0; 512]); + let replacement = Scratch::new(&[0; 512]); + let source = FileSource::open(&original.0).unwrap(); + std::fs::rename(&replacement.0, &original.0).unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + } + + #[test] + fn file_source_detects_same_length_content_changes() { + let scratch = Scratch::new(&[0; 512]); + let source = FileSource::open(&scratch.0).unwrap(); + std::fs::write(&scratch.0, [1; 512]).unwrap(); + // Make the timestamp change deterministic even on filesystems with coarse timestamps. + File::options() + .write(true) + .open(&scratch.0) + .unwrap() + .set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2)) + .unwrap(); + assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading")); + } + fn varint(mut value: u64) -> Vec { let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)]; value >>= 7; @@ -1026,6 +1185,40 @@ mod tests { assert!(tables.iter().any(|table| table.name == "t")); let rows = db.read_table(2, 2).unwrap(); assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1))); + let scratch = Scratch::new(&db.main); + let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap(); + assert_eq!(file_db.live_tables().unwrap(), tables); + assert_eq!(file_db.read_table(2, 2).unwrap(), rows); + assert_eq!(file_db.main.len(), 100); + file_db.ensure_unchanged().unwrap(); + // The same committed WAL page overrides the lazily read main page. + let mut wal = vec![0; 32 + 24 + 512]; + wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes()); + wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes()); + wal[8..12].copy_from_slice(&512u32.to_be_bytes()); + wal[32..36].copy_from_slice(&2u32.to_be_bytes()); + wal[36..40].copy_from_slice(&2u32.to_be_bytes()); + leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']); + let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap(); + let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap(); + assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap()); + assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2)); + let mut malformed = db.main.clone(); + malformed.push(0); + let partial = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(), + "SQLite image is not a whole number of pages" + ); + let mut malformed = db.main.clone(); + malformed[24..28].copy_from_slice(&1u32.to_be_bytes()); + malformed[92..96].copy_from_slice(&1u32.to_be_bytes()); + malformed[28..32].copy_from_slice(&3u32.to_be_bytes()); + let oversized = Scratch::new(&malformed); + assert_eq!( + Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(), + "SQLite header page count exceeds image" + ); } #[test] @@ -1042,6 +1235,7 @@ mod tests { leaf(&mut main[1024..1536], 0, 1, &[2, 0]); leaf(&mut main[1536..], 0, 2, &[2, 0]); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1073,6 +1267,7 @@ mod tests { let mut aliases = HashMap::new(); aliases.insert(1, 0); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1093,6 +1288,7 @@ mod tests { main[103..105].copy_from_slice(&1u16.to_be_bytes()); main[108..110].copy_from_slice(&490u16.to_be_bytes()); let db = Database { + file: None, main, overlay: HashMap::new(), page_size: 512, @@ -1103,6 +1299,8 @@ mod tests { rowid_aliases: HashMap::new(), }; assert!(db.read_table(1, 1).is_err()); + let (_scratch, db) = file_backed(db); + assert!(db.read_table(1, 1).is_err()); } #[test] @@ -1115,6 +1313,7 @@ mod tests { let mut main = vec![0u8; 2 * 512]; let db = Database { + file: None, main: std::mem::take(&mut main), overlay: HashMap::new(), page_size: 512, @@ -1141,6 +1340,10 @@ mod tests { assert_eq!(bytes.len(), 600); assert_eq!(&bytes[..92], &[b'x'; 92]); assert_eq!(&bytes[92..], &[b'y'; 508]); + let (_scratch, mut db) = file_backed(db); + db.overlay.clear(); + let bytes = db.payload(&cell_page, 0, 600).unwrap(); + assert_eq!(&bytes[92..], &[b'y'; 508]); } #[test] @@ -1164,6 +1367,7 @@ mod tests { let mut page = vec![0u8; 512]; page[16..18].copy_from_slice(&512u16.to_be_bytes()); let db = Database { + file: None, main: vec![0; 512], overlay: HashMap::new(), page_size: 512, diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 22d76528d..6805f8537 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 64; +pub const RENDER_CACHE_VERSION: u64 = 65; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; @@ -277,7 +277,9 @@ impl SourceRef { if from.image.width.max(from.image.height) <= *max_edge { return Ok((**from).clone()); } - let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box); + // Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near + // the output's resolution limit and aliases what is finer + let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3); return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() }); } SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, @@ -761,6 +763,24 @@ pub struct RenderJob { /// A face tile that can be cut from the raw's embedded preview, tried before `source` /// ([`crate::Session::face_job`]; `None` for every other job). pub preview_cut: Option, + /// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once + /// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up + /// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at + /// small ratios and aliases it at large ones. The caches keep the full render. + pub display_fit: Option<(usize, usize)>, +} + +/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]). +pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 { + use lightcraft_raster::resample::{Filter, fit}; + let f = lightcraft_raster::Image { + width: img.width, + height: img.height, + data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(), + }; + let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3); + let c = |v: f32| v.round().clamp(0.0, 255.0) as u8; + Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() } } pub struct RenderResult { @@ -784,6 +804,14 @@ pub struct RenderResult { impl RenderJob { /// Render at draft quality (interactive drags). Gets its own result key. + /// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see + /// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again. + pub fn with_display_fit(mut self, w: usize, h: usize) -> Self { + self.display_fit = Some((w, h)); + self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + self + } + pub fn draft(mut self) -> Self { if self.request.quality != Quality::Draft { self.request.quality = Quality::Draft; @@ -994,6 +1022,15 @@ impl RenderJob { { cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); } + // the display fit, after the caches have the full render + if let Some((fw, fh)) = self.display_fit + && self.request.quality == Quality::Full + { + let (w, h) = (rendered.image.width, rendered.image.height); + if w > fw.max(1) || h > fh.max(1) { + rendered.image = fit_for_display(&rendered.image, fw, fh); + } + } RenderResult { request_id: self.request_id, source_key: self.source_key, @@ -1266,6 +1303,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1319,6 +1357,7 @@ impl crate::Session { display: None, slide: None, preview_cut: None, + display_fit: None, }) } @@ -1373,6 +1412,7 @@ impl crate::Session { display: None, slide: None, preview_cut, + display_fit: None, }) } @@ -1798,6 +1838,28 @@ mod thumbnail_hash_tests { mod tests { use super::*; + /// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept; + /// a draft, and a render no larger than the drawn size, are left as rendered. + #[test] + fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() { + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image; + let long = plain.width.max(plain.height); + assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}"); + let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height); + let aspect = |i: &Rgba8| i.width as f32 / i.height as f32; + assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept"); + let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image; + assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk"); + let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image; + assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered"); + let a = s.loupe_job(id, 1024, 1024, true).unwrap(); + let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400); + assert_ne!(a.key, b.key, "the fit is part of the result key"); + } + #[test] fn renders_know_the_original_size_whichever_level_they_come_from() { // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs index c955dcc01..b69a6dca2 100644 --- a/crates/engine/src/segment/mod.rs +++ b/crates/engine/src/segment/mod.rs @@ -75,6 +75,7 @@ pub(crate) struct Input { } /// What a detail pass re-runs. +#[derive(Clone)] #[cfg_attr(not(feature = "sam"), allow(dead_code))] pub(crate) enum Prompt { Clicks(Vec), @@ -440,7 +441,11 @@ impl Session { let job = self.segment_job(id, kind, Tag::Wait, tx, region)?; self.segmenter.worker.submit(job)?; match rx.recv_timeout(WAIT) { - Ok(o) => o.result, + Ok(o) => { + // (shown by the next poll in an app; logged anyway) + self.segmenter.messages.extend(o.notice); + o.result + } Err(_) => Err("the AI model took too long; try again".into()), } } @@ -579,7 +584,7 @@ impl Session { self.segment_queue(id, kind, tag, Some(region))?; } else { let patch = self.segment_wait(id, kind, Some(region))?; - let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false }; + let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false, notice: None }; let mut polled = Polled::default(); self.apply_outcome(outcome, &mut polled); } @@ -618,6 +623,7 @@ impl Session { if o.superseded { return; } + polled.messages.extend(o.notice); match o.tag { Tag::Wait => {} Tag::Prepare => { @@ -711,6 +717,18 @@ impl Session { mod tests { use super::*; + #[cfg(feature = "sam")] + #[test] + fn a_worker_notice_reaches_the_user_with_the_result() { + // the job after which the model runs on the CPU (the GPU failed) says so + let mut s = Session::with_demo(); + let notice = Some(worker::GPU_FAILED.to_string()); + s.segmenter.results.0.send(worker::Outcome { tag: Tag::Prepare, result: Ok(None), superseded: false, notice }).unwrap(); + let polled = s.segment_poll(); + assert_eq!(polled.messages, vec![worker::GPU_FAILED.to_string()]); + assert!(s.segment_poll().messages.is_empty(), "once"); + } + fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask { let l: Vec = (0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect(); diff --git a/crates/engine/src/segment/worker.rs b/crates/engine/src/segment/worker.rs index 5769fd2ae..efbb1c21c 100644 --- a/crates/engine/src/segment/worker.rs +++ b/crates/engine/src/segment/worker.rs @@ -7,6 +7,13 @@ //! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy //! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded //! after [`IDLE_UNLOAD`] without requests (it holds several GB). +//! +//! Where the model can run on the GPU (Metal on macOS), a job that fails there (a panic or an +//! error from candle, e.g. a kernel the GPU's compiler can't build, issue #1974) frees the +//! model and runs once more on the CPU. When that works, the model stays on the CPU for the rest +//! of the session and the job's outcome says so; when it fails too, the GPU wasn't the cause and +//! the next job uses it again. `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` keep the model on +//! the CPU from the start. use std::collections::VecDeque; use std::panic::AssertUnwindSafe; @@ -29,6 +36,7 @@ use crate::media::RenderJob; pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60); /// What to compute. +#[derive(Clone)] pub enum Kind { /// Load the model and encode the photo (so the first click is fast). Prepare, @@ -42,6 +50,12 @@ pub enum Kind { /// Panics (tests of the worker's recovery). #[cfg(test)] Panic, + /// Fails like the model on a GPU that can't run it (a panic or a candle error), and + /// succeeds on the CPU (tests of the CPU fallback). + #[cfg(test)] + FailOnGpu { + panic: bool, + }, } /// One request. @@ -65,6 +79,70 @@ pub struct Outcome { pub result: Result, String>, /// A newer request for the same selection came in; this one was skipped. pub superseded: bool, + /// Something the user should know besides the result ([`GPU_FAILED`]). + pub notice: Option, +} + +/// The notice of the job after which the model runs on the CPU. +pub const GPU_FAILED: &str = + "The GPU couldn't run the AI mask model, so it runs on the CPU until LightCraft restarts (slower). Details are in the log."; + +/// Where the model is loaded. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Placement { + /// On the GPU when there is one (Metal on macOS); a job that fails there is retried on the CPU. + Gpu, + /// On the CPU: this build has no GPU path, the environment switches the GPU off, or the model + /// failed on it. + Cpu, +} + +impl Placement { + fn initial() -> Self { + if !lightcraft_segment::GPU_BUILD { + return Placement::Cpu; + } + if let Some(why) = lightcraft_gpu::env_off_reason() { + log::info!("SAM 3 runs on the CPU ({why})"); + return Placement::Cpu; + } + Placement::Gpu + } +} + +/// Why a job failed. +#[derive(Debug)] +enum Failure { + /// The model failed (a candle error, or a panic): on the GPU, the CPU may run it. + Model(String), + /// Anything else (no model files, the photo, a remote worker): another device won't help. + Other(String), +} + +impl Failure { + fn message(self) -> String { + match self { + Failure::Model(m) | Failure::Other(m) => m, + } + } +} + +impl From for Failure { + fn from(m: String) -> Self { + Failure::Other(m) + } +} + +impl From<&str> for Failure { + fn from(m: &str) -> Self { + Failure::Other(m.into()) + } +} + +impl From for Failure { + fn from(e: lightcraft_segment::Error) -> Self { + if matches!(e, lightcraft_segment::Error::Candle(_)) { Failure::Model(e.to_string()) } else { Failure::Other(e.to_string()) } + } } /// Counters the session reads without locking. @@ -159,7 +237,7 @@ impl Worker { // (re)start the thread let (tx, rx) = mpsc::channel(); let shared = self.shared.clone(); - match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) { + match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD, Placement::initial())) { Ok(t) => { self.thread = Some(t); self.tx = Some(tx.clone()); @@ -191,7 +269,7 @@ fn supersedes(later: &Job, job: &Job) -> bool { } } -fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration) { +fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration, mut placement: Placement) { let mut state = State::default(); let mut queue: VecDeque = VecDeque::new(); loop { @@ -219,41 +297,78 @@ fn run(rx: &mpsc::Receiver, shared: &Shared, idle: Duration) { let done = Done(if is_detail { &shared.detail } else { &shared.pending }); if queue.iter().any(|later| supersedes(later, &job)) { drop(done); - let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true }); + let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true, notice: None }); continue; } - let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) { - Ok(r) => r, - Err(p) => { - let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); - log::error!("SAM 3 panicked: {why}"); - // don't trust what the model kept: load it again next time - state = State::default(); - Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string()) - } + // what a retry on the CPU needs (running the job takes them) + let retry = (placement == Placement::Gpu && remote::configured().is_none()).then(|| (job.kind.clone(), job.render.clone())); + let mut result = attempt(&mut state, shared, &mut job, placement); + let mut notice = None; + let gpu_failed = match &result { + Err(Failure::Model(why)) => Some(why.clone()), + _ => None, }; + if let (Some(why), Some((kind, render))) = (gpu_failed, retry) { + // the GPU may be the cause (e.g. a kernel its compiler can't build, issue #1974): free + // the model there and try once on the CPU + log::warn!("SAM 3 failed on the GPU ({why}); trying the CPU"); + state = State::default(); + (job.kind, job.render) = (kind, render); + match attempt(&mut state, shared, &mut job, Placement::Cpu) { + Ok(r) => { + log::warn!("SAM 3 runs on the CPU from now on: it failed on the GPU and works on the CPU"); + placement = Placement::Cpu; + notice = Some(GPU_FAILED.to_string()); + result = Ok(r); + } + Err(e) => { + // the CPU fails too, so the GPU isn't the cause: the next job uses it again + log::warn!("SAM 3 failed on the CPU too: {}", e.message()); + state = State::default(); + } + } + } + let result = result.map_err(Failure::message); if result.is_err() && !is_detail { state.cache = None; } shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst); drop(done); - let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false }); + let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false, notice }); } } -/// The model for `dir`, loading it when needed. -fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> { +/// Run `job` with the model on `placement`. A panic is a [`Failure::Model`] and drops what the +/// model kept. +fn attempt(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result, Failure> { + match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(state, shared, job, placement))) { + Ok(r) => r, + Err(p) => { + let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + log::error!("SAM 3 panicked: {why}"); + // don't trust what the model kept: load it again next time + *state = State::default(); + Err(Failure::Model("the AI model failed unexpectedly (it will be reloaded on the next try)".into())) + } + } +} + +/// The model for `dir`, loading it on `placement` when needed. +fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path, placement: Placement) -> Result<&'a mut Sam3, Failure> { if state.model.as_ref().is_none_or(|(d, _)| d != dir) { state.model = None; state.cache = None; let _busy = Flag::raise(&shared.encoding); let t = web_time::Instant::now(); - let m = Sam3::load(dir).map_err(|e| e.to_string())?; - log::info!("SAM 3 loaded in {:?}", t.elapsed()); + let m = match placement { + Placement::Gpu => Sam3::load(dir)?, + Placement::Cpu => Sam3::load_cpu(dir)?, + }; + log::info!("SAM 3 loaded in {:?} ({})", t.elapsed(), if m.device().is_cpu() { "CPU" } else { "GPU" }); state.model = Some((dir.to_path_buf(), m)); shared.loaded.store(true, Ordering::SeqCst); } - state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string()) + state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".into()) } /// Render the model input. @@ -275,16 +390,16 @@ fn render(job: &mut Job) -> Result { } /// The model and the encoded photo `job.key` (rendering and encoding it when needed). -fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> { - model(state, shared, &job.dir)?; +fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<(&'a mut Sam3, &'a mut Encoded), Failure> { + model(state, shared, &job.dir, placement)?; if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) { state.cache = None; let input = render(job)?; let _busy = Flag::raise(&shared.encoding); let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?; let t = web_time::Instant::now(); - let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?; - m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?; + let mut enc = m.encode(&input.rgb, input.w, input.h)?; + m.prepare_clicks(&mut enc)?; log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed()); state.cache = Some((job.key, enc)); } @@ -328,7 +443,7 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str) } } #[cfg(test)] - Kind::Panic => return Err("test panic request".into()), + Kind::Panic | Kind::FailOnGpu { .. } => return Err("test panic request".into()), }; let input = if region.is_some() || client.key != Some(job.key) { render(job)? } else { Input { rgb: Vec::new(), w: 0, h: 0 } }; let request = Request { width: input.w, height: input.h, temporary: region.is_some(), operation }; @@ -342,37 +457,44 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str) })) } -fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result, String> { +fn run_job(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result, Failure> { if let Some(endpoint) = remote::configured() { let result = remote_job(state, shared, job, &endpoint); if result.is_err() { state.remote = None; } - return result.map_err(|e| format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry.")); + return result.map_err(|e| Failure::Other(format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry."))); } let side = lightcraft_segment::MASK_SIDE; match std::mem::replace(&mut job.kind, Kind::Prepare) { Kind::Prepare => { - encoded(state, shared, job)?; + encoded(state, shared, job, placement)?; Ok(None) } Kind::Clicks(clicks) => { - let (m, enc) = encoded(state, shared, job)?; - let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?; + let (m, enc) = encoded(state, shared, job, placement)?; + let p = m.segment_clicks(enc, &clicks)?; Ok(Some(SegMask::from_logits(side, &p.logits))) } Kind::Text(text) => { - let (m, enc) = encoded(state, shared, job)?; + let (m, enc) = encoded(state, shared, job, placement)?; Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l))) } #[cfg(test)] #[allow(clippy::panic)] Kind::Panic => panic!("a test panic"), + #[cfg(test)] + #[allow(clippy::panic)] + Kind::FailOnGpu { panic } => match placement { + Placement::Gpu if panic => panic!("a test panic on the GPU"), + Placement::Gpu => Err(Failure::Model("a test error on the GPU".into())), + Placement::Cpu => Ok(None), + }, Kind::Detail { prompt, region } => { let input = render(job)?; - let m = model(state, shared, &job.dir)?; + let m = model(state, shared, &job.dir, placement)?; let t = web_time::Instant::now(); - let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?; + let mut enc = m.encode(&input.rgb, input.w, input.h)?; let (rw, rh) = (region[2] - region[0], region[3] - region[1]); if !(rw > 0.0 && rh > 0.0) { return Ok(None); @@ -388,7 +510,7 @@ fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result text_logits(m, &mut enc, &text)?, }; @@ -438,7 +560,7 @@ mod tests { jtx.send(job(Tag::Wait, &tx)).unwrap(); shared.pending.store(2, Ordering::SeqCst); drop(jtx); - run(&jrx, &shared, Duration::from_secs(60)); + run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu); let first = rx.recv().unwrap(); assert!(first.result.unwrap_err().contains("failed unexpectedly")); let second = rx.recv().unwrap(); @@ -447,6 +569,72 @@ mod tests { assert!(!shared.encoding.load(Ordering::SeqCst)); } + /// Run `kinds` in turn on a worker that starts on `placement`; their outcomes. + fn run_kinds(placement: Placement, kinds: Vec) -> Vec { + let (tx, rx) = mpsc::channel(); + let (jtx, jrx) = mpsc::channel(); + let shared = Shared::default(); + shared.pending.store(kinds.len(), Ordering::SeqCst); + for kind in kinds { + let mut j = job(Tag::Wait, &tx); + j.kind = kind; + jtx.send(j).unwrap(); + } + drop(jtx); + run(&jrx, &shared, Duration::from_secs(60), placement); + assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset"); + assert!(!shared.encoding.load(Ordering::SeqCst)); + rx.try_iter().collect() + } + + #[test] + fn a_job_that_panics_on_the_gpu_runs_on_the_cpu_and_so_do_the_next_ones() { + // issue #1974: candle panics compiling a Metal kernel the GPU's compiler can't build + let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: true }, Kind::FailOnGpu { panic: true }]); + assert_eq!(out.len(), 2); + assert!(matches!(out[0].result, Ok(None)), "retried on the CPU: {:?}", out[0].result.as_ref().err()); + assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED)); + assert!(matches!(out[1].result, Ok(None)), "the next job starts on the CPU"); + assert_eq!(out[1].notice, None, "said once"); + } + + #[test] + fn a_model_error_on_the_gpu_is_retried_on_the_cpu_too() { + let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: false }]); + assert!(matches!(out[0].result, Ok(None))); + assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED)); + } + + #[test] + fn a_job_that_fails_on_the_cpu_too_keeps_the_gpu() { + let out = run_kinds(Placement::Gpu, vec![Kind::Panic, Kind::FailOnGpu { panic: true }]); + assert!(out[0].result.as_ref().unwrap_err().contains("failed unexpectedly")); + assert_eq!(out[0].notice, None); + // not the GPU's fault: the next job tries the GPU first again + assert!(matches!(out[1].result, Ok(None))); + assert_eq!(out[1].notice.as_deref(), Some(GPU_FAILED)); + } + + #[test] + fn a_worker_on_the_cpu_never_tries_the_gpu() { + // no GPU in this build, or LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off + let out = run_kinds(Placement::Cpu, vec![Kind::FailOnGpu { panic: true }]); + assert!(matches!(out[0].result, Ok(None))); + assert_eq!(out[0].notice, None); + if !lightcraft_segment::GPU_BUILD { + assert_eq!(Placement::initial(), Placement::Cpu); + } + } + + #[test] + fn missing_model_files_are_not_retried_on_the_cpu() { + let out = run_kinds(Placement::Gpu, vec![Kind::Prepare]); + assert!(out[0].result.as_ref().unwrap_err().contains("not found")); + assert_eq!(out[0].notice, None); + let missing = lightcraft_segment::Error::Missing(PathBuf::from("models")); + assert!(matches!(Failure::from(missing), Failure::Other(_))); + } + #[test] fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() { let (tx, rx) = mpsc::channel(); @@ -464,7 +652,7 @@ mod tests { drop(jtx); let shared = Shared::default(); shared.pending.store(3, Ordering::SeqCst); - run(&jrx, &shared, Duration::from_secs(60)); + run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu); let out: Vec = rx.try_iter().collect(); assert_eq!(out.len(), 3); assert!(out[0].superseded && out[1].superseded && !out[2].superseded); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index cdee1e894..2e1092140 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -17,7 +17,10 @@ //! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`. //! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then //! the first by file name — [`Session::sidecar_naming`]); the others use `.xmp` -//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. +//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. An import ranks +//! the files it is adding against the library the same way ([`ImportStems`]): a JPEG or a TIFF +//! arriving beside its raw's `IMG_0001.xmp` reads its own `.xmp`, never the raw's packet +//! (issue #1905). //! //! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except //! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`, @@ -25,7 +28,7 @@ //! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:` //! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -71,6 +74,18 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option { sidecar_candidates(original, naming).find(|p| p.is_file()) } +/// Find the sidecar for a file an import is adding, by the ownership [`ImportStems`] worked out +/// (issue #1905): [`find_sidecar`] with the library's naming, except that a file which does not +/// own its stem sidecar reads its own `.xmp` (the name it writes) and never the packet of +/// the sibling that owns the stem one. +pub(crate) fn find_sidecar_for_import(stems: &ImportStems, naming: SidecarNaming, original: &str) -> Option { + if stems.owns(original) { + return find_sidecar(original, naming); + } + let shared = xmp_cases(sidecar_path(original, SidecarNaming::Stem)); + sidecar_candidates(original, SidecarNaming::Full).filter(|p| !shared.contains(p)).find(|p| p.is_file()) +} + /// The names `original`'s sidecar can have, in the order they are looked for: under `naming`, /// then under the other naming (the full name also with the photo extension in the other case), /// each as `.xmp`, `.XMP` and `.Xmp`. @@ -89,7 +104,12 @@ pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl } v }) - .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]) + .flat_map(xmp_cases) +} + +/// The three ways one sidecar name can be written: `.xmp`, `.XMP` and `.Xmp`. +fn xmp_cases(p: PathBuf) -> [PathBuf; 3] { + [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")] } /// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated. @@ -528,6 +548,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec) { } } +/// Where a file stands in the order a shared stem sidecar is owned by: a raw first, then the file +/// name in lower case, then the catalog id (a file an import is adding has no id yet, and ranks +/// last among files of its kind and name: see [`ImportStems`]). +type Rank = (bool, String, u64); + +/// The rank of the file at `path`, of kind `kind`, catalogued as `id`. +fn rank_of(path: &str, kind: MediaKind, id: u64) -> Rank { + (kind != MediaKind::Raw, path.to_lowercase(), id) +} + /// Which photo owns each stem sidecar that several catalogued files map to (see /// [`Session::sidecar_naming`]). pub(crate) struct StemOwners(HashMap); @@ -538,10 +568,10 @@ impl StemOwners { /// owners were worked out before, for every write). #[cfg(test)] pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners { - let mut best: HashMap = HashMap::new(); + let mut best: HashMap = HashMap::new(); for p in cat.photos().filter(|p| p.copy_of.is_none()) { let Some(path) = file_path(p) else { continue }; - let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0); + let rank = rank_of(path, p.kind, p.id.0); let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0)); e.2 += 1; if rank < e.0 { @@ -566,7 +596,7 @@ impl StemOwners { /// The owners of the shared stems in `wanted`. fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet) -> StemOwners { // per stem: (raw first, then file name, then id) of the best candidate; and how many - let mut best: HashMap = HashMap::new(); + let mut best: HashMap = HashMap::new(); let mut key = String::new(); for p in cat.photos().filter(|p| p.copy_of.is_none()) { let Some(path) = file_path(p) else { continue }; @@ -574,7 +604,7 @@ impl StemOwners { if !wanted.contains(key.as_str()) { continue; } - let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0); + let rank = rank_of(path, p.kind, p.id.0); let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0)); e.2 += 1; if rank < e.0 { @@ -593,6 +623,66 @@ impl StemOwners { } } +/// Which of the files an import is adding may read the shared stem sidecar (`IMG_0001.xmp`) of +/// their stem, and which read their own `.xmp` (`IMG_0001.TIF.xmp`) instead (issue #1905: +/// before, a JPEG or a TIFF imported beside its raw read the raw's packet and took its rating, +/// metadata and develop settings). The order is the one [`StemOwners`] applies to writing, with +/// the arriving files ranked against the library and against each other: a raw first, then the +/// file name in lower case. A file that is not catalogued yet has no id, and the id it draws when +/// the import commits is above every id in the library, so it loses a tie against a catalogued +/// file; between arriving files the order they arrive in stands for it. +pub(crate) struct ImportStems { + /// The arriving files that own the stem sidecar of their stem, by path as it arrives. + owners: HashSet, +} + +impl ImportStems { + /// Work out [`ImportStems`] for the files `incoming`, in the order they arrive. `None` for a + /// library already looked over (a job prepared twice): there is nothing to rank against, so + /// every arriving file reads as it did before. The library is passed over once, and only the + /// stems the arriving files map to are looked at closer. + pub(crate) fn for_import<'a>(cat: Option<&lightcraft_catalog::Catalog>, incoming: impl Iterator) -> ImportStems { + // per stem: the best of the library's files, and the best arriving file with its path + let mut best: HashMap, Option<(Rank, &'a str)>)> = HashMap::new(); + let mut arriving_paths = HashSet::new(); + for (arriving, (path, kind)) in incoming.enumerate() { + arriving_paths.insert(path); + let e = best.entry(stem_key(path)).or_default(); + let rank = rank_of(path, kind, arriving as u64); + if e.1.as_ref().is_none_or(|(b, _)| rank < *b) { + e.1 = Some((rank, path)); + } + } + if let Some(cat) = cat { + let mut key = String::new(); + for p in cat.photos().filter(|p| p.copy_of.is_none()) { + // the arriving file's own record (a trashed photo imported again) is not a rival + let Some(path) = file_path(p).filter(|path| !arriving_paths.contains(*path)) else { continue }; + stem_key_into(&mut key, path); + let Some(e) = best.get_mut(key.as_str()) else { continue }; + let rank = rank_of(path, p.kind, p.id.0); + if e.0.as_ref().is_none_or(|b| rank < *b) { + e.0 = Some(rank); + } + } + } + let owners = best + .into_values() + .filter_map(|(in_library, arriving)| { + let (rank, path) = arriving?; + let wins = in_library.as_ref().is_none_or(|b| (rank.0, rank.1.as_str()) < (b.0, b.1.as_str())); + wins.then(|| path.to_string()) + }) + .collect(); + ImportStems { owners } + } + + /// Whether the arriving file at `original` owns the stem sidecar of its stem. + pub(crate) fn owns(&self, original: &str) -> bool { + self.owners.contains(original) + } +} + /// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming` /// ([`Session::sidecar_namings`], for a thread that has the library and not the session). pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec { diff --git a/crates/engine/src/sidecar/tests_stem_owners.rs b/crates/engine/src/sidecar/tests_stem_owners.rs index 43170f4f6..0d86fe92d 100644 --- a/crates/engine/src/sidecar/tests_stem_owners.rs +++ b/crates/engine/src/sidecar/tests_stem_owners.rs @@ -1,4 +1,5 @@ -//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`). +//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`), and +//! for the files an import is adding (`ImportStems`). //! //! A library can hold millions of photos; a sidecar that is written for one of them, or a folder //! that is synchronized, must not cost a table of them all. @@ -37,6 +38,11 @@ const PATHS: [&str; 26] = [ "/pics/dots/a.jpg", ]; +/// The kind a file of this name is catalogued as: the `.cr3` ones are raws. +fn kind_of(path: &str) -> MediaKind { + if path.to_lowercase().ends_with("cr3") { MediaKind::Raw } else { MediaKind::Image } +} + fn library() -> (Catalog, Vec) { let mut c = Catalog::new(); let mut ids = Vec::new(); @@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec) { let id = c.alloc_photo_id(); let name = path.rsplit('/').next().unwrap_or(path); let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01"); - if path.to_lowercase().ends_with("cr3") { - p.kind = MediaKind::Raw; - } + p.kind = kind_of(path); c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); ids.push(id); } @@ -114,6 +118,72 @@ fn owners_among_some_photos_hold_nothing_of_the_rest() { assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]); } +/// Given a library where files share stems in every way a name can, when files arrive in an +/// import, then each of them may read the shared stem sidecar exactly when it would own it once +/// it is catalogued: the arriving files are ranked against the library's files and against each +/// other, as the write rule ranks them (issue #1905). +#[test] +fn an_arriving_file_owns_the_stem_sidecar_exactly_when_it_would_once_catalogued() { + let (c, _) = library(); + // the arrivals: the same names under an upper-case folder, so each shares its stem with the + // library's files (the key lower-cases the path) without being one of them + let incoming: Vec<(String, MediaKind)> = PATHS + .iter() + .map(|p| { + let (dir, name) = p.rsplit_once('/').unwrap_or(("", p)); + (format!("{}/{name}", dir.to_uppercase()), kind_of(p)) + }) + .collect(); + // the library with these arrivals in it, the ids drawn in the order they arrive + let arriving_in = |base: &Catalog, add: &[usize]| -> Catalog { + let mut cat = base.clone(); + for &k in add { + let (path, kind) = &incoming[k]; + let id = cat.alloc_photo_id(); + let name = path.rsplit('/').next().unwrap_or(path); + let mut p = Photo::new(id, Source::File { path: path.clone() }, name, "X", 6, 4, "2026-01-01"); + p.kind = *kind; + cat.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + cat + }; + let owns_once_catalogued = |cat: &Catalog, k: usize| -> bool { + let path = &incoming[k].0; + let p = cat.photos().find(|p| matches!(&p.source, Source::File { path: x } if x == path)).unwrap(); + StemOwners::of(cat).naming(p, SidecarNaming::Stem) == SidecarNaming::Stem + }; + let as_arriving = |ks: &[usize]| -> Vec<(&str, MediaKind)> { ks.iter().map(|k| (incoming[*k].0.as_str(), incoming[*k].1)).collect() }; + let all: Vec = (0..incoming.len()).collect(); + for i in 0..incoming.len() { + let alone = ImportStems::for_import(Some(&c), as_arriving(&[i]).into_iter()); + assert_eq!(alone.owns(&incoming[i].0), owns_once_catalogued(&arriving_in(&c, &[i]), i), "{} alone", incoming[i].0); + for j in 0..incoming.len() { + // every pair arriving together + if i == j { + continue; + } + let pair = ImportStems::for_import(Some(&c), as_arriving(&[i, j]).into_iter()); + let cat = arriving_in(&c, &[i, j]); + for k in [i, j] { + assert_eq!( + pair.owns(&incoming[k].0), + owns_once_catalogued(&cat, k), + "{} with {}", + incoming[k].0, + incoming[if k == i { j } else { i }].0 + ); + } + } + } + // a job prepared twice (a library already looked over) has nothing to rank against: every + // one of them is ranked among the arrivals alone, as it would be in a library of just them + let alone = ImportStems::for_import(None, as_arriving(&all).into_iter()); + let only_arrivals = arriving_in(&Catalog::new(), &all); + for k in &all { + assert_eq!(alone.owns(&incoming[*k].0), owns_once_catalogued(&only_arrivals, *k), "{} without a library", incoming[*k].0); + } +} + /// The key written into a buffer (no allocation for a plain path) is the key: for every path /// above, for shapes a path from a file can have, and for every string of up to six of the /// characters that decide a key (a letter in each case, a dot, both separators, a letter that is diff --git a/crates/engine/src/tests_constrain_crop.rs b/crates/engine/src/tests_constrain_crop.rs new file mode 100644 index 000000000..9f01ed05d --- /dev/null +++ b/crates/engine/src/tests_constrain_crop.rs @@ -0,0 +1,147 @@ +//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the +//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so +//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop. + +use lightcraft_geom::{CropGeometry, Rect}; +use lightcraft_pipeline::geometry::Frame; +use serde_json::json; + +use crate::Session; + +fn crop(s: &Session) -> CropGeometry { + s.develop_of(s.active().unwrap()).unwrap().crop.geometry +} + +/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)? +fn fits(s: &Session) -> bool { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let mut d = (*p.develop).clone(); + d.geometry.constrain_crop = true; + let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref()); + let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect); + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9) +} + +fn smaller(a: Rect, b: Rect) -> bool { + a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3 +} + +fn constrain(s: &mut Session, on: bool) { + s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap(); +} + +fn vertical(s: &mut Session, v: f64) { + s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap(); +} + +#[test] +fn a_geometry_slider_tightens_the_crop_while_constrained() { + let mut s = Session::with_demo(); + let full = crop(&s).rect; + constrain(&mut s, true); + assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off"); + vertical(&mut s, -60.0); + assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect); + assert!(fits(&s), "and holds no blank corner"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider"); +} + +#[test] +fn ticking_constrain_after_the_warp_tightens_the_crop() { + let mut s = Session::with_demo(); + let full = crop(&s).rect; + vertical(&mut s, -60.0); + assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners"); + assert!(!fits(&s)); + constrain(&mut s, true); + assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect); + // unticking keeps the tighter crop, as in Lightroom + let tight = crop(&s).rect; + constrain(&mut s, false); + assert_eq!(crop(&s).rect, tight); +} + +#[test] +fn a_crop_drawn_by_hand_is_kept_as_drawn() { + let mut s = Session::with_demo(); + constrain(&mut s, true); + vertical(&mut s, -60.0); + s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted"); +} + +/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's +/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop. +#[test] +fn auto_sync_fits_each_photos_own_crop() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (a, b) = (ids[0], ids[1]); + // B: a small crop off-centre; both constrained + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap(); + constrain(&mut s, true); + s.selection = crate::Selection { ids: vec![a], active: Some(a) }; + constrain(&mut s, true); + let b_before = s.develop_of(b).unwrap().crop.geometry.rect; + // A active, both selected, Auto Sync on: a Geometry edit on A + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + vertical(&mut s, -60.0); + let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap()); + assert_eq!(db.geometry.vertical, -60.0, "the edit reached B"); + let b_after = db.crop.geometry.rect; + assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own"); + let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9; + assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}"); + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + assert!(fits(&s), "and fits B's warp"); +} + +/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the +/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B. +#[test] +fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let (a, b) = (ids[0], ids[1]); + s.selection = crate::Selection { ids: vec![b], active: Some(b) }; + s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap(); + constrain(&mut s, true); + s.selection = crate::Selection { ids: vec![a], active: Some(a) }; + constrain(&mut s, true); + let b_before = s.develop_of(b).unwrap().crop.geometry.rect; + s.selection = crate::Selection { ids: vec![a, b], active: Some(a) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap(); + for v in [-20.0, -40.0, -60.0] { + vertical(&mut s, v); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap()); + assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0)); + let b_after = db.crop.geometry.rect; + assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own"); + let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9; + assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}"); + for photo in [a, b] { + s.selection = crate::Selection { ids: vec![photo], active: Some(photo) }; + assert!(fits(&s), "each crop fits its photo's warp"); + } +} + +/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning +/// another section's eye off leaves a crop drawn by hand as it is. +#[test] +fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() { + let mut s = Session::with_demo(); + constrain(&mut s, true); + vertical(&mut s, -60.0); + s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap(); + s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap(); + assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change"); + s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap(); + s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap(); + assert!(fits(&s), "the Geometry eye is one: the crop fits again"); +} diff --git a/crates/engine/src/tests_crop_aspect.rs b/crates/engine/src/tests_crop_aspect.rs new file mode 100644 index 000000000..560290b7f --- /dev/null +++ b/crates/engine/src/tests_crop_aspect.rs @@ -0,0 +1,86 @@ +//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5", +//! "original") takes the crop's present orientation, landscape or portrait after X, as in +//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop. + +use serde_json::{Value, json}; + +use crate::Session; + +/// The demo photo, landscape, with its crop's width and height in pixels. +fn crop_px(s: &Session) -> (f64, f64) { + let p = s.catalog.photo(s.active().unwrap()).unwrap(); + let (w, h) = (p.width as f64, p.height as f64); + let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }; + let r = p.develop.crop.geometry.rect_px(w, h); + (r.width(), r.height()) +} + +fn landscape_photo() -> Session { + let mut s = Session::with_demo(); + let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap(); + s.selection = crate::Selection { ids: vec![id], active: Some(id) }; + s +} + +fn aspect(s: &mut Session, p: Value) { + s.execute("crop.aspect", &p).unwrap(); +} + +fn ratio(s: &Session) -> f64 { + let (w, h) = crop_px(s); + w / h +} + +#[test] +fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": "2x3"})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s)); + s.execute("crop.rotateAspect", &json!({})).unwrap(); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s)); + // another named ratio after X stays portrait (it used to turn back to the image's landscape) + aspect(&mut s, json!({"aspect": "4x5"})); + assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s)); + aspect(&mut s, json!({"aspect": "16x9"})); + assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s)); +} + +#[test] +fn a_custom_ratio_is_used_as_given() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": [2, 3]})); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s)); + aspect(&mut s, json!({"aspect": [3, 2]})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s)); +} + +/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a +/// landscape photo it lands landscape, whatever photo it was chosen on. +#[test] +fn orientation_overrides_and_is_checked() { + let mut s = landscape_photo(); + aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"})); + assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s)); + aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"})); + assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s)); + let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err(); + assert!(e.to_string().contains("orientation"), "{e}"); +} + +/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked +/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes. +#[test] +fn a_bad_orientation_is_refused_before_anything_is_locked() { + let mut s = landscape_photo(); + let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop; + for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] { + for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] { + let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err(); + assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}"); + assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched"); + } + } + // the valid words still lock as is + aspect(&mut s, json!({"aspect": "current", "orientation": "crop"})); + assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some()); +} diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 0ac8df804..4830490b5 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -98,6 +98,43 @@ fn dng_export_rejects_non_raw_photos() { let _ = std::fs::remove_dir_all(&dir); } +/// Review of #1966: a preset without `format` keeps its `bitDepth` until the format written is +/// known. With `path: a.png` it writes the 16-bit PNG it asks for; a format without that depth +/// gets its own, as stored settings do, while a depth the call itself passes is still refused. +#[test] +fn a_preset_depth_is_judged_against_the_format_written() { + use crate::export::ExportPreset; + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.export_presets.push(ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16, "longEdge": 64}) }); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.png"})).unwrap(); + let o = ExportOptions::from_params(&p).unwrap(); + assert_eq!((o.format, o.bit_depth), (ExportFormat::Png, Some(16))); + assert_eq!(export_photo(&mut s, id, &o, 1).unwrap().bytes[24], 16, "a 16-bit PNG"); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg"})).unwrap(); + assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, None); + let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg", "bitDepth": 16})).unwrap(); + assert!(ExportOptions::from_params(&p).unwrap_err().to_string().contains("`bitDepth` must be 8 for jpeg")); + // such a preset can be saved: its depth is judged when it exports; with a format, at once + s.execute("export.savePreset", &json!({"name": "Deep 2", "params": {"bitDepth": 16}})).unwrap(); + assert!(s.execute("export.savePreset", &json!({"name": "Deep 3", "params": {"format": "jpeg", "bitDepth": 16}})).is_err()); +} + +/// Review of #1966: savePreset without `params` saves the previous export, a stored setting. A +/// depth written before #1952 at the format's own depth is dropped, as Export with Previous does, +/// instead of refusing the save; without `format`, the depth waits for the format written. +#[test] +fn a_preset_saved_from_the_previous_export_drops_its_unfit_depth() { + let mut s = Session::with_demo(); + s.last_export = Some(json!({"format": "jpeg", "bitDepth": 16, "quality": 80, "dir": "/out"})); + s.execute("export.savePreset", &json!({"name": "Old"})).unwrap(); + assert_eq!(s.export_presets[0].params, json!({"format": "jpeg", "quality": 80})); + s.last_export = Some(json!({"bitDepth": 16, "dir": "/out"})); + s.execute("export.savePreset", &json!({"name": "Deep"})).unwrap(); + let p = s.export_params(&json!({"preset": "Deep", "path": "/out/a.png"})).unwrap(); + assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, Some(16)); +} + #[test] fn presets_conflicts_and_subfolders() { use crate::export::{Destination, export_batch}; @@ -991,7 +1028,8 @@ fn same_folder_as_original_exports_next_to_each_photo() { let exists = |p: &str| std::path::Path::new(p).exists(); let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap(); let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect(); - let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()]; + // `export` joins the subfolder and name with `/` (`join_path`), on Windows too. + let mut want = vec![format!("{}/Exports/IMG_1.jpg", a.to_string_lossy()), format!("{}/Exports/IMG_1.jpg", b.to_string_lossy())]; let mut got: Vec = written.iter().map(|p| p.to_string()).collect(); want.sort(); got.sort(); diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests_history_cursor.rs index ffe300795..2c22c00e3 100644 --- a/crates/engine/src/tests_history_cursor.rs +++ b/crates/engine/src/tests_history_cursor.rs @@ -115,3 +115,95 @@ fn negative_indices_are_refused() { assert!(s.execute("version.rename", &json!({"index": -1, "name": "B"})).is_err()); assert_eq!(s.catalog.photo(id).unwrap().versions[0].name, "A", "version 0 was not renamed"); } + +#[test] +fn fractional_version_delete_preserves_the_version() { + let mut s = Session::with_demo(); + s.execute("version.create", &json!({"name": "A"})).unwrap(); + let before = s.catalog.to_snapshot(); + let result = s.execute("version.delete", &json!({"index": 0.9})); + assert!(result.is_err(), "index 0.9 must be refused; got {result:?}; catalog unchanged: {}", s.catalog.to_snapshot() == before); + assert_eq!(s.catalog.to_snapshot(), before, "the version must remain unchanged"); +} + +/// Invalid command indices must leave objects, settings, History and undo unchanged. +#[test] +fn invalid_command_indices_leave_the_catalog_and_history_unchanged() { + let (mut s, _) = three_edits(); + for name in ["A", "B"] { + s.execute("version.create", &json!({"name": name})).unwrap(); + } + s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap(); + for x in [0.3, 0.6] { + s.execute("redeye.add", &json!({"center": [x, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap(); + } + let before = s.catalog.to_snapshot(); + let history = s.execute("history.list", &json!({})).unwrap(); + let undo_len = s.undo.len(); + // The integer beyond u64 is parsed as a float by serde_json; it must not saturate to usize::MAX. + let oversized: serde_json::Value = serde_json::from_str("18446744073709551616").unwrap(); + for command in [ + "version.delete", + "version.rename", + "version.update", + "version.restore", + "pointColor.delete", + "redeye.delete", + "redeye.catchlight", + "history.restore", + ] { + for index in [json!(0.5), json!(1.9), json!(-1), json!(-0.5), json!("0"), json!(null), json!(u64::MAX), oversized.clone()] { + let result = s.execute(command, &json!({"index": index, "name": "Changed"})); + assert!(result.is_err(), "{command} accepted {index}: {result:?}"); + assert_eq!(s.catalog.to_snapshot(), before, "{command} changed the catalog for {index}"); + assert_eq!(s.execute("history.list", &json!({})).unwrap(), history, "{command} moved the History cursor for {index}"); + assert_eq!(s.undo.len(), undo_len, "{command} added an undo step for {index}"); + } + assert!(s.execute(command, &json!({"name": "Changed"})).is_err(), "{command} accepted a missing index"); + assert_eq!(s.catalog.to_snapshot(), before); + } +} + +/// Whole floating-point JSON numbers remain valid for existing control and MCP callers. +#[test] +fn whole_command_indices_accept_integer_and_float_numbers() { + let (mut s, n) = three_edits(); + let id = s.active().unwrap(); + s.execute("history.restore", &json!({"index": (n - 3) as f64})).unwrap(); + assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3)))); + s.execute("history.restore", &json!({"index": n - 1})).unwrap(); + assert_eq!(exposure(&s), 3.0); + for name in ["A", "B", "C"] { + s.execute("version.create", &json!({"name": name})).unwrap(); + } + s.execute("version.rename", &json!({"index": 2.0, "name": "Third"})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().versions[2].name, "Third"); + s.execute("develop.set", &json!({"control": "light.exposure", "value": -2.0})).unwrap(); + s.execute("version.update", &json!({"index": 1.0})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().versions[1].settings.light.exposure, -2.0); + s.execute("version.restore", &json!({"index": 0.0})).unwrap(); + assert_eq!(exposure(&s), 3.0); + s.execute("version.delete", &json!({"index": 1})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().versions.iter().map(|v| v.name.as_str()).collect::>(), ["A", "Third"]); + s.execute("version.delete", &json!({"index": 1.0})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().versions.len(), 1); + s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap(); + s.execute("pointColor.delete", &json!({"index": 0.0})).unwrap(); + assert!(s.develop_of(id).unwrap().point_colors.is_empty()); + s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap(); + s.execute("redeye.catchlight", &json!({"index": 0.0})).unwrap(); + assert!(s.develop_of(id).unwrap().red_eye[0].catchlight.is_some()); + s.execute("redeye.delete", &json!({"index": 0.0})).unwrap(); + assert!(s.develop_of(id).unwrap().red_eye.is_empty()); +} + +#[test] +fn fractional_history_restore_preserves_the_cursor() { + let (mut s, _) = three_edits(); + let before = s.execute("history.list", &json!({})).unwrap(); + let result = s.execute("history.restore", &json!({"index": 1.9})); + let after = s.execute("history.list", &json!({})).unwrap(); + assert!(result.is_err(), "index 1.9 must be refused; got {result:?}; History unchanged: {}", before == after); + assert_eq!(after, before); + assert_eq!(exposure(&s), 3.0); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 13f02ce77..c5ff90456 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1132,6 +1132,25 @@ fn import_can_replace_a_duplicate_from_recently_deleted() { let _ = std::fs::remove_dir_all(&src); } +/// A trashed photo imported again as a new one owns its stem sidecar: its own old record is not a +/// rival for `a.xmp` (issue #1905's ranking), so the fresh photo reads it. +#[test] +fn a_fresh_reimport_reads_its_own_stem_sidecar() { + let (mut s, src, id) = trashed_photo("trash-fresh-sidecar"); + std::fs::write( + src.join("a.xmp"), + r#" +"#, + ) + .unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap(); + assert_eq!(ids(&r, "imported"), 1, "{r}"); + let new = r["imported"][0].as_u64().unwrap(); + assert_ne!(new, id); + assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap().rating, 3, "the fresh photo reads a.xmp: {r}"); + let _ = std::fs::remove_dir_all(&src); +} + #[test] fn unknown_on_deleted_is_an_error() { let mut s = Session::new().with_fs(); @@ -1792,3 +1811,94 @@ fn presets_apply_the_imported_profile_they_name() { assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid"); let _ = std::fs::remove_dir_all(&src); } + +/// A raw container LightCraft shows by its embedded JPEG: a TIFF whose IFD0 points at one. +fn write_raw(path: &Path) { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let px: Vec = (0..48 * 32).flat_map(|i| [(i % 251) as u8, 91, 201]).collect(); + let j = encode_jpeg(&EncodeImage::new(48, 32, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let off = 8 + 2 + 24 + 4; + let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&j); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, f).unwrap(); +} + +/// An edited TIFF of a raw: the same name, a TIFF (not a raw) to LightCraft. +fn write_tiff(path: &Path, seed: u8) { + use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, TiffCompression, encode_tiff}; + let (w, h) = (48usize, 32usize); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed]).collect(); + let bytes = encode_tiff(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), TiffCompression::Deflate, &EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// Issue #1905 (the import side of #1523): the stem sidecar `IMG_1.xmp` belongs to the raw first, +/// so a TIFF or JPEG arriving beside a raw of the same name reads its own `IMG_1.TIF.xmp`, never +/// the raw's packet, with or without the raw. Before, the arriving file took the raw's rating, +/// label, metadata and develop settings. +#[test] +fn a_file_arriving_beside_its_raws_stem_reads_its_own_sidecar() { + let sidecar = |rating: u8| { + format!( + r#" +"# + ) + }; + let read = |s: &Session, name: &str| -> u8 { s.catalog.photos().find(|p| p.file_name == name).map(|p| p.rating).unwrap_or_default() }; + + // the raw and the edited TIFF of it, same name, in one import + let src = temp_dir("stem-pair"); + write_raw(&src.join("IMG_1.NEF")); + write_tiff(&src.join("IMG_1.TIF"), 7); + std::fs::write(src.join("IMG_1.xmp"), sidecar(5)).unwrap(); + std::fs::write(src.join("IMG_1.TIF.xmp"), sidecar(2)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}"); + assert_eq!(r["sidecars"], 2, "{r}"); + assert_eq!(read(&s, "IMG_1.NEF"), 5, "the raw reads the stem sidecar, which is its own"); + assert_eq!(read(&s, "IMG_1.TIF"), 2, "the TIFF reads its own sidecar, never the raw's"); + let _ = std::fs::remove_dir_all(&src); + + // the TIFF with no sidecar of its own: nothing of the raw's packet comes across + let src = temp_dir("stem-none"); + write_raw(&src.join("IMG_2.NEF")); + write_tiff(&src.join("IMG_2.TIF"), 8); + std::fs::write(src.join("IMG_2.xmp"), sidecar(4)).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}"); + assert_eq!(r["sidecars"], 1, "{r}"); + assert_eq!(read(&s, "IMG_2.NEF"), 4); + assert_eq!(read(&s, "IMG_2.TIF"), 0, "the raw's packet is not the TIFF's"); + let _ = std::fs::remove_dir_all(&src); + + // the raw already in the library, the edited TIFF arriving later + use lightcraft_catalog::{MediaKind, Op, Photo, Source}; + let src = temp_dir("stem-after"); + write_raw(&src.join("IMG_3.NEF")); + write_tiff(&src.join("IMG_3.TIF"), 9); + std::fs::write(src.join("IMG_3.xmp"), sidecar(3)).unwrap(); + let mut s = Session::new().with_fs(); + let raw = { + let id = s.catalog.alloc_photo_id(); + let path = src.join("IMG_3.NEF").to_string_lossy().into_owned(); + let mut p = Photo::new(id, Source::File { path }, "IMG_3.NEF", "NEF", 48, 32, "2026-01-14T05:58:48"); + p.kind = MediaKind::Raw; + p + }; + s.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("IMG_3.TIF").to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}"); + assert_eq!(r["sidecars"], 0, "{r}"); + assert_eq!(read(&s, "IMG_3.TIF"), 0, "the catalogued raw owns IMG_3.xmp"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests_persist.rs index 4fb3f0234..7ebf8464b 100644 --- a/crates/engine/src/tests_persist.rs +++ b/crates/engine/src/tests_persist.rs @@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() { s.close_library().unwrap(); assert_eq!(s.unsaved(), None); } + +/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as +/// unsaved (the top bar warns, the web backup refuses) until a later save writes it. +#[test] +fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() { + let files = Flaky::default(); + let mut s = Session::new(); + let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, true).unwrap(); + assert!(s.unsaved().is_none()); + files.set_failing(true); + s.execute("preset.create", &json!({"name": "Mine"})).unwrap(); + let (n, why) = s.unsaved().expect("the preset is unsaved"); + assert_eq!(n, 1); + assert!(why.contains("presets") && why.contains("disk full"), "{why}"); + let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default(); + assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine")); + // still failing: still unsaved + s.persist().unwrap(); + assert!(s.unsaved().is_some()); + files.set_failing(false); + s.persist().unwrap(); + assert!(s.unsaved().is_none(), "written once the disk works again"); + assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine")); +} diff --git a/crates/engine/src/tests_slide_template.rs b/crates/engine/src/tests_slide_template.rs index f7a899c43..9d3e9c807 100644 --- a/crates/engine/src/tests_slide_template.rs +++ b/crates/engine/src/tests_slide_template.rs @@ -248,6 +248,9 @@ fn bad_files_are_reported_and_skipped() { } std::fs::create_dir_all(folder.join("folder.json")).unwrap(); std::fs::write(folder.join("notes.txt"), b"ignored").unwrap(); + // written by hand, not through LightCraft: the folder's time may not have moved since the + // save's listing (same clock tick), so the cached listing is dropped as a write would drop it + s.slide_templates_cache = None; let r = run(&mut s, "slideshow.templates.list", json!({})); let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect(); assert_eq!(user.len(), 1, "only the good one: {r}"); diff --git a/crates/engine/src/tests_spots.rs b/crates/engine/src/tests_spots.rs index 988e7aac1..28a9fca0b 100644 --- a/crates/engine/src/tests_spots.rs +++ b/crates/engine/src/tests_spots.rs @@ -82,3 +82,242 @@ fn a_content_aware_spot_has_no_source() { assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill"); assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err()); } + +fn target_session() -> Session { + let mut s = Session::with_demo(); + s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.addComponent", &json!({"kind": "colorRange"})).unwrap(); + s.execute("mask.saveEffect", &json!({"id": 1, "name": "Existing"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap(); + for x in [0.3, 0.7] { + s.execute("spot.add", &json!({"points": [[x, 0.5]], "source": [0.1, 0.0]})).unwrap(); + } + // A rejected command must preserve a redo branch as well as existing undo steps. + s.execute("mask.rename", &json!({"name": "Temporary"})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s +} + +fn target_state(s: &Session) -> serde_json::Value { + let photo = s.catalog.photo(s.active().unwrap()).unwrap(); + json!({ + "develop": photo.develop, + "history": photo.history, + "selection": s.selection, + "activeMask": s.active_mask, + "activeSpot": s.active_spot, + "undo": format!("{:?}", s.undo), + "redo": format!("{:?}", s.redo), + "commits": s.commits(), + "effects": s.mask_effects, + "rememberedEffects": s.mask_tool_effects, + "journal": s.journal, + "revision": s.catalog.revision, + }) +} + +fn rejects_target(s: &mut Session, command: &str, params: &serde_json::Value) { + let before = target_state(s); + let result = s.execute(command, params); + let after = target_state(s); + assert!(result.is_err(), "{command} {params} returned {result:?}; state unchanged: {}", before == after); + assert_eq!(after, before, "{command} {params} changed edits, selection or history on rejection"); + for field in ["id", "component", "index"] { + if let Some(value) = params.get(field) + && !value.is_null() + && value.as_u64().is_none() + { + assert!( + result.unwrap_err().to_string().contains(&format!("`{field}`")), + "{command} must reject the malformed {field} before doing other work" + ); + break; + } + } +} + +fn malformed_targets() -> Vec { + vec![json!(-1), json!(0.5), json!("1"), json!(true), json!([]), json!({}), json!(u64::MAX)] +} + +#[test] +fn malformed_mask_ids_are_rejected_without_changes() { + let mut s = target_session(); + let commands = [ + ("mask.delete", json!({})), + ("mask.rename", json!({"name": "Wrong mask"})), + ("mask.invert", json!({})), + ("mask.visible", json!({"visible": false})), + ("mask.duplicate", json!({})), + ("mask.move", json!({"to": 0})), + ("mask.adjust", json!({"values": {"exposure": 1.0}})), + ("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})), + ("mask.addComponent", json!({"kind": "brush"})), + ("mask.component", json!({"component": 0, "action": "invert"})), + ("mask.brushStroke", json!({"points": [[0.5, 0.5]]})), + ("mask.sampleColor", json!({"x": 0.5, "y": 0.5})), + ("mask.objectPoint", json!({"x": 0.5, "y": 0.5})), + ("mask.refineDetail", json!({})), + ]; + for (command, mut params) in commands { + for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) { + params["id"] = target; + rejects_target(&mut s, command, ¶ms); + } + } +} + +#[test] +fn malformed_mask_selection_is_rejected_without_changes() { + let mut s = target_session(); + for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_297_u64)]) { + rejects_target(&mut s, "mask.select", &json!({"id": target})); + } +} + +#[test] +fn overflowing_mask_id_does_not_wrap_to_an_existing_mask() { + let mut s = target_session(); + rejects_target(&mut s, "mask.delete", &json!({"id": 4_294_967_297_u64})); +} + +#[test] +fn malformed_mask_effect_targets_are_rejected_without_changes() { + let mut s = target_session(); + for (command, mut params) in [ + ("mask.saveEffect", json!({"name": "Wrong effect"})), + ("mask.updateEffect", json!({"name": "Existing"})), + ("mask.applyEffect", json!({"name": "Existing"})), + ] { + for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) { + params["id"] = target; + rejects_target(&mut s, command, ¶ms); + } + } + for target in malformed_targets().into_iter().chain([json!([1, -1]), json!([1, null]), json!([1, 4_294_967_297_u64])]) { + rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": target})); + } +} + +#[test] +fn mask_effect_targets_preserve_explicit_defaults_and_lists() { + let mut s = target_session(); + s.execute("mask.saveEffect", &json!({"id": 1, "name": "Explicit"})).unwrap(); + assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 0.0); + for (name, target) in [("Omitted", json!({})), ("Null", json!({"id": null}))] { + let mut params = target; + params["name"] = json!(name); + s.execute("mask.saveEffect", ¶ms).unwrap(); + assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 1.0); + params["name"] = json!("Existing"); + s.execute("mask.updateEffect", ¶ms).unwrap(); + assert_eq!(s.mask_effects[0].adjust.exposure, 1.0); + } + s.execute("mask.updateEffect", &json!({"id": 1, "name": "Existing"})).unwrap(); + assert_eq!(s.mask_effects[0].adjust.exposure, 0.0); + for mut params in [json!({}), json!({"id": null}), json!({"ids": null})] { + s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap(); + params["name"] = json!("Existing"); + s.execute("mask.applyEffect", ¶ms).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].adjust.exposure, 0.0); + } + s.execute("mask.adjust", &json!({"id": 1, "values": {"exposure": 1.0}})).unwrap(); + s.execute("mask.adjust", &json!({"id": 2, "values": {"exposure": 2.0}})).unwrap(); + s.execute("mask.applyEffect", &json!({"name": "Existing", "ids": [1, 2]})).unwrap(); + assert!(s.develop_of(s.active().unwrap()).unwrap().masks.iter().all(|m| m.adjust.exposure == 0.0)); + rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": []})); +} + +#[test] +fn malformed_mask_components_are_rejected_without_changes() { + let mut s = target_session(); + for (command, mut params) in [ + ("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})), + ("mask.component", json!({"action": "invert"})), + ("mask.sampleColor", json!({"x": 0.5, "y": 0.5})), + ("mask.refineDetail", json!({})), + ] { + for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) { + params["component"] = target; + rejects_target(&mut s, command, ¶ms); + } + } +} + +#[test] +fn malformed_spot_indices_are_rejected_without_changes() { + let mut s = target_session(); + for (command, mut params) in [("spot.delete", json!({})), ("spot.update", json!({"size": 0.1})), ("spot.refreshSource", json!({}))] { + for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) { + params["index"] = target; + rejects_target(&mut s, command, ¶ms); + } + } +} + +#[test] +fn malformed_spot_selection_is_rejected_without_changes() { + let mut s = target_session(); + for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_296_u64)]) { + rejects_target(&mut s, "spot.select", &json!({"index": target})); + } +} + +#[test] +fn mask_and_spot_targets_preserve_explicit_defaults_and_selection_clearing() { + use lightcraft_develop::MaskShape; + let mut s = target_session(); + for target in [json!({}), json!({"id": null})] { + let mut params = target; + params["name"] = json!("Selected mask"); + s.execute("mask.rename", ¶ms).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].name, "Selected mask"); + } + s.execute("mask.rename", &json!({"id": 1, "name": "Explicit mask"})).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[0].name, "Explicit mask"); + assert_eq!(s.active_mask, Some(2)); + for params in [json!({"shape": {"kind": "brush", "strokes": []}}), json!({"component": null, "shape": {"kind": "brush", "strokes": []}})] { + s.execute("mask.update", ¶ms).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + assert!(matches!(d.masks[1].components[0].shape, MaskShape::Brush { .. })); + assert!(matches!(d.masks[1].components[1].shape, MaskShape::ColorRange { .. })); + } + s.execute("mask.update", &json!({"id": 1, "component": 0, "shape": {"kind": "sky"}})).unwrap(); + assert!(matches!(s.develop_of(s.active().unwrap()).unwrap().masks[0].components[0].shape, MaskShape::Sky)); + for params in [json!({"points": [[0.2, 0.2]]}), json!({"id": null, "points": [[0.4, 0.4]]})] { + s.execute("mask.brushStroke", ¶ms).unwrap(); + } + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 2); + for params in [json!({"x": 0.5, "y": 0.5}), json!({"component": null, "x": 0.5, "y": 0.5})] { + s.execute("mask.sampleColor", ¶ms).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + let MaskShape::ColorRange { samples, .. } = &d.masks[1].components[1].shape else { panic!("color range") }; + assert_eq!(samples.len(), 1); + } + for params in [json!({"size": 0.04}), json!({"index": null, "size": 0.04})] { + s.execute("spot.update", ¶ms).unwrap(); + assert_eq!(spots(&s)[1].size, 0.04); + } + s.execute("spot.update", &json!({"index": 0, "size": 0.03})).unwrap(); + assert_eq!(spots(&s)[0].size, 0.03); + assert_eq!(s.active_spot, Some(1)); + rejects_target(&mut s, "mask.component", &json!({"component": null, "action": "delete"})); + for params in [json!({"id": null}), json!({})] { + s.execute("mask.select", &json!({"id": 1})).unwrap(); + assert_eq!(s.active_mask, Some(1)); + s.execute("mask.select", ¶ms).unwrap(); + assert_eq!(s.active_mask, None); + } + for params in [json!({"index": null}), json!({})] { + s.execute("spot.select", &json!({"index": 0})).unwrap(); + assert_eq!(s.active_spot, Some(0)); + s.execute("spot.select", ¶ms).unwrap(); + assert_eq!(s.active_spot, None); + } + rejects_target(&mut s, "mask.delete", &json!({})); + rejects_target(&mut s, "spot.delete", &json!({})); + // An omitted mask still creates a brush mask when no mask is selected. + s.execute("mask.brushStroke", &json!({"points": [[0.5, 0.5]]})).unwrap(); + assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 3); +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index b0409962d..7efae6a57 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -75,12 +75,8 @@ fn record_fallback(reason: String) { /// E.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …". /// Never blocks. pub fn unavailable_reason() -> Option { - if env_disabled() { - #[cfg(not(target_arch = "wasm32"))] - if backend::env_off() { - return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()); - } - return Some("disabled by LIGHTCRAFT_GPU=0".into()); + if let Some(why) = env_off_reason() { + return Some(why.into()); } if !ENABLED.load(Ordering::Relaxed) { return Some("disabled by the GPU rendering preference (app.gpu)".into()); @@ -245,12 +241,26 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool { /// environment switches and the rendering preference, but not a render-device failure (the devices are separate). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn switched_off() -> Option { - if env_disabled() { - return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into()); + if let Some(why) = env_off_reason() { + return Some(why.into()); } (!ENABLED.load(Ordering::Relaxed)).then(|| "disabled by the GPU rendering preference (app.gpu)".into()) } +/// Why the environment switches the GPU off for the whole process (`LIGHTCRAFT_GPU=0` or +/// `LIGHTCRAFT_GPU_BACKEND=off`), or `None`. Code with a GPU path of its own (the SAM 3 model on +/// Metal) follows it too. +pub fn env_off_reason() -> Option<&'static str> { + if !env_disabled() { + return None; + } + #[cfg(not(target_arch = "wasm32"))] + if backend::env_off() { + return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off"); + } + Some("disabled by LIGHTCRAFT_GPU=0") +} + /// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process. fn env_disabled() -> bool { static OFF: std::sync::OnceLock = std::sync::OnceLock::new(); diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 0c59afa6a..759f73d71 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -193,6 +193,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; fn with_reply(reply: &[u8], check: impl FnOnce(&mut Remote, Result)) { + let _serial = crate::serial_sockets_and_children(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let reply = reply.to_vec(); @@ -307,6 +308,7 @@ mod tests { #[test] fn consecutive_replies_match_their_requests_on_one_connection() { + let _serial = crate::serial_sockets_and_children(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let worker = std::thread::spawn(move || { @@ -331,6 +333,7 @@ mod tests { #[test] fn lost_reply_is_not_resent() { + let _serial = crate::serial_sockets_and_children(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let seen = Arc::new(AtomicUsize::new(0)); @@ -353,6 +356,7 @@ mod tests { #[test] fn connection_closed_by_app_reconnects_before_sending() { + let _serial = crate::serial_sockets_and_children(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let seen = Arc::new(AtomicUsize::new(0)); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 7c9af704d..fcdfcd622 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -61,16 +61,13 @@ impl Headless { /// With `path` and no `format`, the format follows the path's extension. fn export(&mut self, p: &Value) -> Result { use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, prepare_batch, run_batch}; - let p = &self.session.export_params(p)?; - let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; + let p = &self.session.export_params_or(p, ExportFormat::Png)?; + // PNG for a `path` that names no format; `bitDepth` is checked against the format written + let mut opts = ExportOptions::from_params_or(p, ExportFormat::Png).map_err(|e| e.to_string())?; if !ExportOptions::has_size_param(p) { opts.resize = Some(Resize::long_edge(3000)); } let exact = p.get("path").and_then(Value::as_str); - if let (Some(path), None) = (exact, p.get("format")) { - let ext = Path::new(path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); - opts.format = ExportFormat::parse(&ext).unwrap_or(ExportFormat::Png); - } let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(), // like the desktop app: the selection (in grid order), else the given / active photo diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index f632f145b..4f9251bcb 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -33,3 +33,14 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7980"; #[cfg(test)] mod tests; + +/// Held by tests that start a child process (a copy of the test binary) and by tests that run a +/// loopback socket server, so the two never overlap. On Windows the child can inherit a socket the +/// server just accepted; when the child exits, Windows closes its copy abortively and the test's +/// connection is reset ("An existing connection was forcibly closed"), which failed the +/// `backend` reply tests whenever they ran alongside a child-process test. +#[cfg(test)] +pub(crate) fn serial_sockets_and_children() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} diff --git a/crates/mcp/src/progress_tests.rs b/crates/mcp/src/progress_tests.rs index fb7859dc5..29db30a89 100644 --- a/crates/mcp/src/progress_tests.rs +++ b/crates/mcp/src/progress_tests.rs @@ -100,6 +100,7 @@ fn progress_flush_failure_does_not_wait_for_input_eof() { } fn run_shutdown_case(mode: &str) { + let _serial = crate::serial_sockets_and_children(); let mut child = Command::new(std::env::current_exe().unwrap()) .args(["--exact", "server::progress::shutdown_tests::output_failure_child", "--ignored", "--nocapture"]) .env("LIGHTCRAFT_MCP_SHUTDOWN_CASE", mode) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 99193b480..59e7ba3eb 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -147,6 +147,43 @@ fn export_refuses_unknown_params_and_bad_values() { assert!(e.contains("did you mean `quality`"), "{e}"); } +/// Review of #1966: a `path` whose extension names no format exports PNG through the headless +/// backend, so its `bitDepth` is checked against PNG's, not let through to an 8-bit file. +#[test] +fn export_checks_the_depth_of_the_format_a_bare_path_gets() { + let mut b = Headless::demo(); + let base = std::env::temp_dir().join(format!("lc-mcp-bare-path-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + for name in ["output", "output.xyz"] { + let e = b.call("app.export", json!({"path": base.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10})).unwrap_err(); + assert!(e.contains("`bitDepth` must be 8 or 16 for png"), "{name}: {e}"); + } + assert_eq!(std::fs::read_dir(&base).unwrap().count(), 0, "nothing written"); + // the format such a path gets is still PNG, and 16 bits is one of its depths + b.call("app.export", json!({"path": base.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 16})).unwrap(); + let written = std::fs::read_dir(&base).unwrap().next().unwrap().unwrap().path(); + let bytes = std::fs::read(&written).unwrap(); + assert!(bytes.starts_with(b"\x89PNG"), "{written:?} is a PNG"); + assert_eq!(bytes[24], 16, "{written:?} is 16-bit"); + let _ = std::fs::remove_dir_all(&base); +} + +/// Review of #1966: a preset's `bitDepth` is judged against the format this backend writes, PNG +/// for a `path` that names none, so a 16-bit preset without `format` writes a 16-bit PNG. +#[test] +fn a_preset_depth_is_judged_against_the_format_written() { + let mut b = Headless::demo(); + let base = std::env::temp_dir().join(format!("lc-mcp-preset-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + b.session.export_presets.push(lightcraft_engine::export::ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16}) }); + b.call("app.export", json!({"preset": "Deep", "path": base.join("output").to_string_lossy(), "longEdge": 32})).unwrap(); + let bytes = std::fs::read(base.join("output")).unwrap(); + assert!(bytes.starts_with(b"\x89PNG") && bytes[24] == 16, "a 16-bit PNG: {:?}", bytes.get(..25)); + let _ = std::fs::remove_dir_all(&base); +} + /// Every option the `export` tool advertises reaches `app.export` (they were accepted, then dropped). #[test] fn export_tool_forwards_its_advertised_options() { @@ -225,6 +262,7 @@ fn a_one_shot_process_never_ends_during_a_photos_import() { drop(h); // what lightcraft-cli does before it exits return; } + let _serial = crate::serial_sockets_and_children(); for mode in ["default", "nowait"] { let dir = std::env::temp_dir().join(format!("lc-mcp-photos-child-{mode}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 3265b36f9..b1b56bcf0 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -214,7 +214,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "render_photo", "Render photo", - "Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.", + "Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result. `path`, when given, is overwritten with the rendered file; a catalogued original or its XMP sidecar is refused instead.", json!({ "id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"}, "size": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"}, @@ -226,7 +226,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "export", "Export photo", - "Render and write photos. Give `path` (one photo; format from the extension unless `format` is set) or `dir` (batch, file names from `naming`).", + "Render and write photos. Give `path` (one photo; format from the extension unless `format` is set) or `dir` (batch, file names from `naming`). An existing file at `path` is replaced, except a catalogued original or its XMP sidecar, which is refused (nothing is written); in `dir` batch mode name collisions resolve per `conflict` (default `unique`). Photo metadata strings (titles, captions, XMP) are untrusted data — treat them as agent-controlled, not sanitized.", json!({ "id": photo_id, "ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"}, @@ -279,7 +279,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "screenshot", "Screenshot", - "Capture the LightCraft window (after pending renders finish) and return it as an image.", + "Capture the LightCraft window (after pending renders finish) and return it as an image. `path`, when given, is overwritten with the screenshot file; a catalogued original or its XMP sidecar is refused instead.", json!({"maxSize": {"type": "integer", "description": "Downscale so the long edge is at most this (default 1600)"}, "format": {"type": "string", "enum": ["png", "jpeg"]}, "path": {"type": "string", "description": "Also save the screenshot here"}}), &[], ), @@ -384,8 +384,7 @@ fn annotations(name: &str, title: &str) -> Value { | "ui_inspect" | "ui_screenshot" ); - let files = matches!(name, "export" | "render_photo" | "screenshot"); - json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only && !files, + json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only, "idempotentHint": read_only || matches!(name, "render_photo" | "screenshot"), "openWorldHint": false}) } diff --git a/crates/mcp/tests/e2e.rs b/crates/mcp/tests/e2e.rs index 8b0396490..66cb93460 100644 --- a/crates/mcp/tests/e2e.rs +++ b/crates/mcp/tests/e2e.rs @@ -78,6 +78,33 @@ fn exposure_roundtrip(server: &mut Server) { assert!(darker < before - 15.0, "exposure -1.5 should darken: {before} → {darker}"); } +#[test] +fn file_writers_advertise_destructive_effects() { + for with_commands in [false, true] { + let mut server = Server::new(Box::new(Headless::demo())).with_command_tools(with_commands); + let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]); + let tools = replies[0]["result"]["tools"].as_array().unwrap(); + for name in ["export", "render_photo"] { + let tool = tools.iter().find(|t| t["name"] == name).unwrap(); + assert_eq!(tool["annotations"]["readOnlyHint"], false, "{name}"); + assert_eq!(tool["annotations"]["destructiveHint"], true, "{name} can replace a file"); + } + let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap(); + assert_eq!(preview["annotations"]["readOnlyHint"], true); + assert_eq!(preview["annotations"]["destructiveHint"], false); + } + let addr = fake_app(); + let mut server = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]); + let tools = replies[0]["result"]["tools"].as_array().unwrap(); + let screenshot = tools.iter().find(|t| t["name"] == "screenshot").unwrap(); + assert_eq!(screenshot["annotations"]["readOnlyHint"], false); + assert_eq!(screenshot["annotations"]["destructiveHint"], true); + let image_only = tools.iter().find(|t| t["name"] == "ui_screenshot").unwrap(); + assert_eq!(image_only["annotations"]["readOnlyHint"], true); + assert_eq!(image_only["annotations"]["destructiveHint"], false); +} + #[test] fn headless_set_exposure_and_render() { exposure_roundtrip(&mut Server::new(Box::new(Headless::demo()))); diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index 2b2ea1d13..40471b360 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -453,6 +453,7 @@ pub fn write_exif(meta: &Metadata) -> Vec { (t::F_NUMBER, r(meta.f_number)), (t::FOCAL_LENGTH, r(meta.focal_length)), (t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))), + (t::ISO_SPEED_RATINGS_EXT, meta.iso.filter(|&i| i > 65_534).map(|i| Value::Long(vec![i]))), (t::LENS_MAKE, ascii(&meta.lens_make)), (t::LENS_MODEL, ascii(&meta.lens_model)), (t::EXPOSURE_BIAS, meta.exposure_bias.map(|v| Value::SRational(vec![srational(v)]))), @@ -518,4 +519,12 @@ mod write_tests { let g = back.gps.unwrap(); assert!((g.latitude - 43.7904).abs() < 1e-5 && (g.longitude + 110.6818).abs() < 1e-5, "{g:?}"); } + + #[test] + fn iso_above_65534_round_trips() { + for iso in [65_534, 65_535, 102_400, 409_600] { + let back = read_exif(&write_exif(&Metadata { iso: Some(iso), ..Default::default() })); + assert_eq!(back.iso, Some(iso)); + } + } } diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index f2aac5881..c9a7dada9 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -484,6 +484,11 @@ pub fn push_pressures(pressure: &mut Vec, added: usize, from: f32, to: f32) /// pointer is held still (the same screen position mapped again, up to float rounding). const STATIONARY_PX: f64 = 1e-6; +/// Most dabs one stroke holds (32 MiB of centres). A segment is capped on its own, but a stroke of +/// many hostile segments (an agent, a crafted XMP or catalog) would otherwise add up without +/// bound; past this the rest of the stroke lays nothing (#1520). +pub const MAX_STROKE_DABS: usize = 2_000_000; + /// The dabs of stroke `s` in a `w × h` output. pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDabs { let to_out = frame.norm_to_out(w, h); @@ -537,11 +542,21 @@ pub fn dab_spacing(r: f64) -> f64 { /// their dabs with this, so they cover the same pixels (issue #517). A sample where the pointer /// hasn't moved (a stationary hold records one per frame) or that is not a finite point lays /// nothing, so holding the brush still adds no coverage (#518). Returns whether `q` was stamped; -/// callers move `prev` on only then. +/// callers move `prev` on only then. A stroke holding [`MAX_STROKE_DABS`] takes no more. pub fn push_dabs(dabs: &mut Vec, prev: Option, q: Point, r: f64) -> bool { - if !(q.x.is_finite() && q.y.is_finite()) { + let mut emitted = dabs.len(); + push_dabs_counted(dabs, &mut emitted, prev, q, r) +} + +/// Append one segment using a persistent count of all dabs emitted for this stroke, including +/// dabs no longer held in `dabs`. Incremental callers retain `emitted` when clearing their buffer; +/// it advances only by the number actually appended. Acceptance and spacing match [`push_dabs`]. +pub fn push_dabs_counted(dabs: &mut Vec, emitted: &mut usize, prev: Option, q: Point, r: f64) -> bool { + let room = MAX_STROKE_DABS.saturating_sub(*emitted); + if room == 0 || !(q.x.is_finite() && q.y.is_finite()) { return false; } + let before = dabs.len(); if let Some(prev) = prev { let dist = prev.dist(q); if dist.is_nan() || dist <= STATIONARY_PX { @@ -551,11 +566,14 @@ pub fn push_dabs(dabs: &mut Vec, prev: Option, q: Point, r: f64) - // a hostile or degenerate segment (huge) gets no in-between dabs rather than a huge // allocation: 1 M dabs is far more than any on-screen stroke needs let n = if n.is_finite() { n.clamp(0.0, 1_000_000.0) as usize } else { 0 }; + // the in-between dabs and `q` together fit what is left of the stroke's budget + let n = n.min(room); for k in 1..n { dabs.push(prev.lerp(q, k as f64 / n as f64)); } } dabs.push(q); + *emitted += dabs.len() - before; true } @@ -838,6 +856,17 @@ mod tests { } assert_eq!(committed.dabs, again); assert!(committed.dabs.windows(2).all(|w| w[0].dist(w[1]) <= dab_spacing(committed.r) + 1e-9)); + // many huge segments stop at the stroke budget instead of adding up without bound + let mut big = Vec::new(); + let mut at = Point::new(0.0, 0.0); + for i in 0..5 { + let q = Point::new(if i % 2 == 0 { 1e12 } else { 0.0 }, 0.0); + if push_dabs(&mut big, Some(at), q, 1.0) { + at = q; + } + } + assert!(big.len() <= MAX_STROKE_DABS, "{} dabs", big.len()); + assert!(!push_dabs(&mut big, Some(at), Point::new(5.0, 5.0), 1.0), "a full stroke takes no more"); // a hostile segment places no in-between dabs let mut v = Vec::new(); assert!(!push_dabs(&mut v, Some(Point::new(f64::NAN, 0.0)), Point::new(1.0, 1.0), r)); @@ -846,6 +875,33 @@ mod tests { assert!(v.len() <= 1_000_002, "{}", v.len()); } + /// A temporary output buffer must spend the same stroke budget as a retained one. + #[test] + fn incremental_dab_buffers_share_the_stroke_budget() { + let points = [Point::new(20.0, 20.0), Point::new(80.0, 20.0), Point::new(200.0, 80.0), Point::new(21.0, 20.0)]; + let mut retained = Vec::new(); + let mut streamed = Vec::new(); + let mut retained_count = MAX_STROKE_DABS - 3; + let mut streamed_count = retained_count; + let mut prev = None; + for q in points { + let mut chunk = Vec::new(); + let accepted = push_dabs_counted(&mut retained, &mut retained_count, prev, q, 10.0); + assert_eq!(accepted, push_dabs_counted(&mut chunk, &mut streamed_count, prev, q, 10.0)); + streamed.extend(chunk); + if accepted { + prev = Some(q); + } + } + assert_eq!(retained, streamed); + assert_eq!(retained.len(), 3); + assert_eq!(retained_count, MAX_STROKE_DABS); + assert_eq!(streamed_count, retained_count); + assert_eq!(prev, Some(points[1])); + assert!(!retained.contains(&points[2])); + assert!(!retained.contains(&points[3])); + } + /// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile /// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. #[test] diff --git a/crates/raw/src/jxl.rs b/crates/raw/src/jxl.rs index 5bfbb22de..209f8f4de 100644 --- a/crates/raw/src/jxl.rs +++ b/crates/raw/src/jxl.rs @@ -11,11 +11,18 @@ //! //! Lossless (non-XYB) tiles are rendered in the codestream's own colour encoding, which is the identity, //! keeping the values in the raw IFD's sample space. Lossy (XYB) tiles are decoded by jxl-oxide to linear -//! sRGB primaries; the codestream's declared primaries are then restored with a plain 3×3 matrix. We -//! never let jxl-oxide convert to the declared encoding itself: that step gamut-maps and clips to -//! [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of 32768 puts them far above) and -//! desaturates. The result is the linear sample space the DNG's `WhiteLevel` refers to, up to the -//! codec's loss, and can exceed 1.0 or dip below 0. +//! sRGB primaries; the codestream's declared encoding is then restored by hand: its primaries with a +//! plain 3×3 matrix, then its transfer curve. The samples of a lossy tile mean what they would in a +//! lossless one with the same header, so a declared transfer curve is part of the stored values, not a +//! display hint. Lightroom Classic's lossy LinearRaw DNGs (Convert to DNG with lossy compression) store +//! perceptually shaped samples, label them with the sRGB curve and linearise them with a cubic +//! `MapPolynomial` per plane in `OpcodeList2`. Measured against Lightroom's own preview of two such +//! files, the polynomial applied to the samples in the declared encoding tracks Lightroom's rendering +//! (midtone log-log slope 0.98 and 1.38, about 1 EV apart); applied to the decoder's linear output it is +//! 3.6 EV too dark. We never let jxl-oxide convert to the declared encoding itself: +//! that step gamut-maps and clips to [0, 1], which destroys raw values above 1.0 (a `WhiteLevel` of +//! 32768 puts them far above) and desaturates. The result is the sample space the DNG's `WhiteLevel` +//! refers to, up to the codec's loss, and can exceed 1.0 or dip below 0. //! //! Every tile is decoded with wide (32-bit) modular buffers. A lossy tile whose header sets //! `modular_16bit_buffers` can still carry values that need more than 16 bits; the narrow buffers then @@ -79,20 +86,49 @@ fn rgb_to_xyz(p: [[f64; 2]; 3]) -> Matrix { std::array::from_fn(|i| std::array::from_fn(|j| prim[i][j] * s[j])) } +/// A declared transfer curve, applied to linear values (linear → coded). Mirrored for negative values +/// and not clipped above 1.0, so lossy undershoot and raw values above white survive. +#[derive(Debug, Clone, Copy, PartialEq)] +enum Curve { + Linear, + /// IEC 61966-2-1 (sRGB). + Srgb, + /// ITU-R BT.709. + Bt709, + /// A pure power: coded = linear^exponent. + Power(f32), +} + +impl Curve { + fn encode(self, v: f32) -> f32 { + let a = v.abs(); + let e = match self { + Curve::Linear => return v, + Curve::Srgb if a <= 0.003_130_8 => 12.92 * a, + Curve::Srgb => 1.055 * a.powf(1.0 / 2.4) - 0.055, + Curve::Bt709 if a < 0.018 => 4.5 * a, + Curve::Bt709 => 1.099 * a.powf(0.45) - 0.099, + Curve::Power(p) => a.powf(p), + }; + e.copysign(v) + } +} + /// How a chunk is rendered by jxl-oxide. #[derive(Debug, Clone, Copy, PartialEq)] enum Output { /// In the codestream's own colour encoding (non-XYB: the identity). AsCoded, - /// XYB (lossy) data: linear sRGB primaries from the decoder, then `Some(matrix)` to the declared - /// primaries (`None` when those are sRGB's). - FromXyb(Option<[[f32; 3]; 3]>), + /// XYB (lossy) data: linear sRGB primaries from the decoder, then `matrix` to the declared + /// primaries (`None` when those are sRGB's), then the declared transfer `curve`. + FromXyb { matrix: Option<[[f32; 3]; 3]>, curve: Curve }, } /// Decide how to render a chunk from its header: whether it is XYB-coded, the declared colour -/// encoding and the intensity target. Lossy data is only accepted when the declared encoding is a -/// linear RGB one with a D65 white point (what a raw DNG writer signals) and no HDR tone mapping would -/// apply, otherwise the samples cannot be restored faithfully. +/// encoding and the intensity target. Lossy data is only accepted when the declared encoding is an RGB +/// one with a D65 white point and an SDR transfer curve (linear, sRGB, BT.709 or a power: what raw DNG +/// writers signal) and no HDR tone mapping would apply, otherwise the samples cannot be restored +/// faithfully. fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, planes: usize) -> Result { if !xyb { return Ok(Output::AsCoded); @@ -104,8 +140,26 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane let ColourEncoding::Enum(e) = encoding else { return Err(unsupported("an ICC profile instead of a colour encoding")); }; - if e.colour_space != ColourSpace::Rgb || e.tf != TransferFunction::Linear { - return Err(unsupported("a declared encoding that is not linear RGB")); + if e.colour_space != ColourSpace::Rgb { + return Err(unsupported("a declared encoding that is not RGB")); + } + let curve = match e.tf { + TransferFunction::Linear => Curve::Linear, + TransferFunction::Srgb => Curve::Srgb, + TransferFunction::Bt709 => Curve::Bt709, + TransferFunction::Dci => Curve::Power(1.0 / 2.6), + // as jxl-oxide reads it: a codestream's gamma is the encoding exponent (×1e7) with `inverted` + TransferFunction::Gamma { g, inverted } => { + let g = g as f32 / 1e7; + Curve::Power(if inverted { g } else { 1.0 / g }) + } + TransferFunction::Pq | TransferFunction::Hlg => return Err(unsupported("an HDR transfer curve (PQ or HLG)")), + TransferFunction::Unknown => return Err(unsupported("an unknown transfer curve")), + }; + if let Curve::Power(p) = curve + && !(p.is_finite() && p > 0.0) + { + return Err(unsupported("an invalid gamma")); } if e.white_point != WhitePoint::D65 { return Err(unsupported("a white point other than D65")); @@ -114,7 +168,7 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane return Err(unsupported("an intensity target above 255 nits")); } let primaries = match e.primaries { - Primaries::Srgb => return Ok(Output::FromXyb(None)), + Primaries::Srgb => return Ok(Output::FromXyb { matrix: None, curve }), Primaries::Bt2100 => BT2100, Primaries::P3 => P3, Primaries::Custom { red, green, blue } => [red, green, blue].map(|c| c.as_float().map(f64::from)), @@ -126,7 +180,7 @@ fn output_for(xyb: bool, encoding: &ColourEncoding, intensity_target: f32, plane if m.iter().flatten().any(|v| !v.is_finite()) { return Err(unsupported("degenerate primaries")); } - Ok(Output::FromXyb(Some(m.map(|r| r.map(|v| v as f32))))) + Ok(Output::FromXyb { matrix: Some(m.map(|r| r.map(|v| v as f32))), curve }) } fn corrupt(e: impl std::fmt::Display) -> RawError { @@ -177,7 +231,7 @@ fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))), }; let output = output_for(header.metadata.xyb_encoded, &header.metadata.colour_encoding, header.metadata.tone_mapping.intensity_target, planes)?; - if matches!(output, Output::FromXyb(_)) { + if matches!(output, Output::FromXyb { .. }) { img.request_color_encoding(EnumColourEncoding::srgb_linear(RenderingIntent::Relative)); } Ok((img, max, output)) @@ -205,18 +259,23 @@ pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp } let n = cw.saturating_mul(ch).saturating_mul(cpp); let rows = fb.buf().chunks_exact(jw * fc); - // lossy data: restore the declared primaries (see the module documentation) - let matrix = match output { - Output::FromXyb(m) => m, - Output::AsCoded => None, + // lossy data: restore the declared primaries and transfer curve (see the module documentation) + let (matrix, curve) = match output { + Output::FromXyb { matrix, curve } => (matrix, curve), + Output::AsCoded => (None, Curve::Linear), }; - let restore = |s: &[f32], d: &mut [f32]| match matrix { - Some(m) => { - for (d, row) in d.iter_mut().zip(&m) { - *d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2]; + let restore = |s: &[f32], d: &mut [f32]| { + match matrix { + Some(m) => { + for (d, row) in d.iter_mut().zip(&m) { + *d = row[0] * s[0] + row[1] * s[1] + row[2] * s[2]; + } } + None => d.copy_from_slice(&s[..cpp]), + } + if curve != Curve::Linear { + d.iter_mut().for_each(|v| *v = curve.encode(*v)); } - None => d.copy_from_slice(&s[..cpp]), }; Ok(if float { let mut out = vec![0f32; n]; @@ -257,7 +316,7 @@ mod tests { fn matrix(o: Output) -> [[f32; 3]; 3] { match o { - Output::FromXyb(Some(m)) => m, + Output::FromXyb { matrix: Some(m), .. } => m, other => panic!("expected a matrix, got {other:?}"), } } @@ -275,7 +334,47 @@ mod tests { #[test] fn lossy_data_with_srgb_primaries_needs_no_matrix() { - assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb(None)); + assert_eq!(output_for(true, &linear(Primaries::Srgb), 255.0, 3).unwrap(), Output::FromXyb { matrix: None, curve: Curve::Linear }); + } + + #[test] + fn lossy_data_keeps_the_declared_transfer_curve() { + // Lightroom Classic's lossy LinearRaw DNGs: Rec.2100 primaries, sRGB transfer curve + let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Perceptual); + e.primaries = Primaries::Bt2100; + e.tf = TransferFunction::Srgb; + let curve = |tf| { + let mut e = e.clone(); + e.tf = tf; + match output_for(true, &ColourEncoding::Enum(e), 255.0, 3).unwrap() { + Output::FromXyb { matrix: Some(_), curve } => curve, + other => panic!("expected a matrix and a curve, got {other:?}"), + } + }; + assert_eq!(curve(TransferFunction::Srgb), Curve::Srgb); + assert_eq!(curve(TransferFunction::Bt709), Curve::Bt709); + assert_eq!(curve(TransferFunction::Dci), Curve::Power(1.0 / 2.6)); + // a codestream's gamma (as read: inverted) is the encoding exponent; jxl-oxide's own γ 2.2 is not + assert_eq!(curve(TransferFunction::Gamma { g: 4_545_455, inverted: true }), Curve::Power(0.454_545_5)); + assert_eq!(curve(TransferFunction::Gamma { g: 22_000_000, inverted: false }), Curve::Power(1.0 / 2.2)); + } + + #[test] + fn transfer_curves_encode_unclipped_and_mirrored() { + let close = |a: f32, b: f32| (a - b).abs() < 1e-4; + // sRGB: linear toe, 18 % grey, white, and the toe/power junction is continuous + assert!(close(Curve::Srgb.encode(0.002), 0.002 * 12.92)); + assert!(close(Curve::Srgb.encode(0.18), 0.461_356)); + assert!(close(Curve::Srgb.encode(1.0), 1.0)); + assert!(close(Curve::Srgb.encode(0.003_130_8), Curve::Srgb.encode(0.003_130_9))); + assert!(close(Curve::Bt709.encode(1.0), 1.0)); + assert!((Curve::Bt709.encode(0.017_999) - Curve::Bt709.encode(0.018)).abs() < 1e-3); + assert!(close(Curve::Power(0.5).encode(0.25), 0.5)); + // raw values above white keep growing, lossy undershoot keeps its sign, linear is the identity + assert!(Curve::Srgb.encode(2.0) > 1.3); + assert!(close(Curve::Srgb.encode(-0.18), -0.461_356)); + assert_eq!(Curve::Linear.encode(-3.5), -3.5); + assert_eq!(Curve::Power(0.5).encode(0.0), 0.0); } #[test] @@ -301,13 +400,80 @@ mod tests { assert!(srgb_green_in_p3.iter().all(|v| (0.0..=1.0).contains(v)), "sRGB green lies inside P3"); } + // 16 × 16 RGB tiles of 16-bit samples coded lossily (XYB) by libjxl at distance 0.5, each declaring BT.2020 + // primaries, a D65 white point and the named transfer curve. The samples are [`gradient`]; generated for these + // tests (original work). + const SRGB_TILE: &[u8] = &[ + 0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xc7, 0x8d, 0x00, 0x13, 0x08, 0x00, 0xf0, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca, + 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83, + 0x84, 0x84, 0x33, 0x70, 0xde, 0x90, 0x97, 0xbd, 0x79, 0x64, 0xe4, 0x3b, 0x62, 0xaf, 0x1b, 0xb2, 0x74, 0xf6, 0x36, 0x43, 0x17, 0x72, 0xe7, + 0x31, 0x86, 0xb6, 0x41, 0x1c, 0x00, 0x15, 0x52, 0x20, 0x11, 0x70, 0xcb, 0x46, 0x32, 0x92, 0x65, 0xcd, 0xc8, 0x18, 0xc9, 0x48, 0xe6, 0x49, + 0xe6, 0x4f, 0x94, 0x35, 0xc7, 0x5c, 0x91, 0xb0, 0x5d, 0xdf, 0x2b, 0x9b, 0x93, 0x3b, 0xb6, 0x70, 0x7d, 0x24, 0xf0, 0xdd, 0x26, 0xb2, 0x26, + 0xdf, 0x49, 0x5f, 0xd4, 0xaa, 0x1c, 0x8f, 0x7d, 0xc4, 0xf2, 0x6a, 0xd2, 0x27, 0x86, 0x93, 0xe7, 0x15, 0x9a, 0x01, 0x60, 0x84, 0xc0, 0x09, + ]; + const BT709_TILE: &[u8] = &[ + 0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xa7, 0x08, 0x00, 0x13, 0x08, 0x00, 0xe8, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca, + 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83, + 0x84, 0x84, 0x33, 0xd8, 0x9c, 0x32, 0xe2, 0x85, 0x6f, 0x3e, 0x6c, 0xa2, 0x1c, 0xaa, 0xdf, 0x1b, 0x32, 0xf4, 0xef, 0x36, 0xe3, 0x96, 0xf2, + 0xe0, 0x65, 0xe8, 0x15, 0xc4, 0x01, 0x50, 0x21, 0x05, 0xd2, 0x00, 0xd8, 0x8c, 0x8c, 0x14, 0x59, 0xc9, 0x31, 0x32, 0x46, 0x52, 0x60, 0x82, + 0x71, 0x03, 0xd9, 0xff, 0x8a, 0x3e, 0x7c, 0xb4, 0xc1, 0xa8, 0x49, 0xfa, 0x83, 0xa8, 0x74, 0xd7, 0x1d, 0xa8, 0xb4, 0x25, 0x67, 0xc1, 0x83, + 0x93, 0x3a, 0xf3, 0x81, 0xb5, 0x2f, 0x44, 0x50, 0x35, 0x36, 0x68, 0x1c, 0xf9, 0x20, 0x17, 0x98, 0x87, 0x1d, 0x83, 0xb0, 0x02, + ]; + const GAMMA22_TILE: &[u8] = &[ + 0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0xf7, 0x75, 0xab, 0x28, 0x02, 0x00, 0x13, 0x08, 0x00, 0xf8, 0x01, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, + 0xa1, 0x32, 0xca, 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, + 0xdb, 0x0b, 0x83, 0x84, 0x84, 0x33, 0xd8, 0x9c, 0x02, 0xed, 0x85, 0x6f, 0x3e, 0x2c, 0x72, 0x1e, 0xb8, 0xe3, 0x3b, 0x12, 0x75, 0xf8, 0x4e, + 0xa3, 0x97, 0x71, 0xea, 0x31, 0x43, 0xa7, 0x20, 0x0e, 0x80, 0x0a, 0x29, 0x90, 0x08, 0xc0, 0x8a, 0x8c, 0x8a, 0x64, 0x74, 0x59, 0x45, 0xc6, + 0x48, 0x46, 0x32, 0x4c, 0x32, 0x4c, 0xa0, 0xa5, 0x39, 0x23, 0xff, 0xdf, 0xcd, 0xf9, 0x33, 0x93, 0x5c, 0xb4, 0xec, 0xc1, 0x06, 0xf5, 0xcc, + 0x92, 0x12, 0xf4, 0xbb, 0xef, 0x15, 0x84, 0xc7, 0xf2, 0xf1, 0x48, 0xfc, 0x80, 0xb9, 0x7a, 0xab, 0x58, 0x3e, 0x79, 0x3a, 0xcf, 0x67, 0xc7, + 0xd3, 0xfc, 0x80, 0x60, 0x00, + ]; + const LINEAR_TILE: &[u8] = &[ + 0xff, 0x0a, 0x43, 0xf0, 0x93, 0x90, 0x47, 0x8b, 0x00, 0x13, 0x08, 0x00, 0x14, 0x02, 0x77, 0x3f, 0xc8, 0x01, 0x00, 0x50, 0xa1, 0x32, 0xca, + 0xb8, 0xc1, 0xcb, 0xb9, 0x9e, 0x2f, 0x3f, 0x74, 0x58, 0x4c, 0xdb, 0xb8, 0xce, 0xd0, 0x56, 0xdb, 0x16, 0x96, 0x30, 0x36, 0xdb, 0x0b, 0x83, + 0x84, 0x84, 0x43, 0xd8, 0x36, 0xe1, 0xbe, 0x88, 0x9b, 0x27, 0x48, 0x71, 0x04, 0xe2, 0xeb, 0x4e, 0xf9, 0x74, 0x9d, 0xe7, 0x00, 0x8d, 0x74, + 0xcb, 0xc0, 0xe0, 0x5d, 0x58, 0x10, 0x80, 0x0a, 0x29, 0x90, 0x06, 0xd8, 0xac, 0x18, 0x19, 0x45, 0xc5, 0x28, 0x89, 0x8a, 0x24, 0xd3, 0xff, + 0xff, 0x1c, 0xc4, 0x2f, 0xd8, 0x26, 0x80, 0x6c, 0xe1, 0xc3, 0xbe, 0xef, 0xab, 0xe1, 0x79, 0x1e, 0xff, 0x1b, 0x2e, 0x57, 0xf7, 0x2c, 0x1f, + 0x1f, 0xf8, 0xec, 0xd3, 0x33, 0x21, 0x9e, 0x12, 0x22, 0x43, 0xc2, 0xfd, 0xb3, 0xf2, 0xe0, 0x40, 0xdf, 0x25, 0x0f, 0x67, 0xe7, 0xca, 0x7e, + 0xa5, 0x7f, 0x1f, 0x21, 0xa4, 0x6a, 0x1e, 0xa0, 0x0e, + ]; + + /// The samples the fixture tiles were coded from: `(x, y)` → RGB. + fn gradient(x: usize, y: usize) -> [f32; 3] { + let (x, y) = (x as f32, y as f32); + [4000.0 + x * 3500.0, 6000.0 + y * 3200.0, 30000.0 + (x - y) * 1500.0] + } + + #[test] + fn lossy_tiles_decode_to_their_samples_whatever_sdr_curve_they_declare() { + for (name, tile) in [("sRGB", SRGB_TILE), ("BT.709", BT709_TILE), ("gamma 2.2", GAMMA22_TILE), ("linear", LINEAR_TILE)] { + let ChunkPx::U16(px) = decode(tile, 16, 16, 16, 16, 3, 16, false).unwrap() else { panic!("{name}: integer samples") }; + let (mut sum, mut worst) = (0.0f32, 0.0f32); + for (i, p) in px.as_chunks::<3>().0.iter().enumerate() { + for (got, want) in p.iter().zip(gradient(i % 16, i / 16)) { + let err = (f32::from(*got) - want).abs(); + sum += err; + worst = worst.max(err); + } + } + // the reference decoder (libjxl) lands within 0.5 % on average and 6 % at worst on these tiles + let mean = sum / px.len() as f32; + assert!(mean < 0.01 * 65535.0, "{name}: mean error {mean}"); + assert!(worst < 0.1 * 65535.0, "{name}: worst error {worst}"); + } + } + #[test] fn lossy_data_that_cannot_be_restored_is_unsupported() { let bad = |xyb_planes: usize, e: ColourEncoding, it: f32| matches!(output_for(true, &e, it, xyb_planes), Err(RawError::Unsupported(_))); assert!(bad(1, linear(Primaries::Srgb), 255.0), "grey"); assert!(bad(3, linear(Primaries::Srgb), 10_000.0), "HDR intensity target would tone-map"); - assert!(bad(3, ColourEncoding::Enum(EnumColourEncoding::srgb_gamma22(RenderingIntent::Relative)), 255.0), "non-linear"); let mut e = EnumColourEncoding::srgb_linear(RenderingIntent::Relative); + for tf in [TransferFunction::Pq, TransferFunction::Hlg, TransferFunction::Unknown, TransferFunction::Gamma { g: 0, inverted: true }] { + e.tf = tf; + assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "{tf:?}"); + } + e.tf = TransferFunction::Linear; e.white_point = WhitePoint::Dci; assert!(bad(3, ColourEncoding::Enum(e.clone()), 255.0), "white point"); e.white_point = WhitePoint::D65; diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 2800f0823..6171b4808 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -71,6 +71,15 @@ use serde::{Deserialize, Serialize}; /// Upper bound on decoded samples (guards allocations driven by header values). pub const MAX_SAMPLES: usize = 1 << 30; +/// Checked product of input-derived dimensions, in u64 so it cannot wrap +/// on either 32-bit (wasm32) or 64-bit targets. The arguments are u64 too, so callers can feed +/// unchecked products of header values (row bytes, sample counts) without truncating on 32-bit. +/// +/// Returns `None` on overflow, zero, or when the product exceeds [`MAX_SAMPLES`]. +pub(crate) fn checked_pixel_count(width: u64, height: u64) -> Option { + width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_SAMPLES as u64) +} + /// Errors from raw decoding. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum RawError { @@ -765,8 +774,14 @@ impl RawImage { if self.cpp == 2 { return Err(RawError::Unsupported("2 samples per pixel".into())); } - if samples && self.data.len() != self.width * self.height * self.cpp { - return Err(RawError::Corrupt("data length mismatch".into())); + if samples { + let expected = crate::checked_pixel_count(self.width as u64, self.height as u64) + .and_then(|n| n.checked_mul(self.cpp as u64)) + .ok_or_else(|| RawError::Corrupt("data length mismatch".into()))?; + let actual = u64::try_from(self.data.len()).map_err(|_| RawError::Corrupt("data length mismatch".into()))?; + if actual != expected { + return Err(RawError::Corrupt("data length mismatch".into())); + } } if let Some(c) = &self.cfa && (!c.valid() || c.pattern.iter().any(|&p| p > 2)) @@ -1416,3 +1431,40 @@ mod tests { assert_eq!(decode(b"junk"), Err(RawError::NotRaw)); } } + +#[cfg(test)] +mod checked_arithmetic_tests { + use super::*; + + #[test] + fn checked_pixel_count_wrap_inputs_rejected() { + // 65536 × 65536 = 2^32 wraps to 0 on wasm32 usize; u64 math keeps it exact. + assert_eq!(checked_pixel_count(65536, 65536), None); + assert_eq!(checked_pixel_count(65536, 65537), None); + // u32::MAX × 2 wraps on 32-bit usize. + assert_eq!(checked_pixel_count(u64::from(u32::MAX), 2), None); + // inputs wider than a 32-bit usize reach the gate untruncated (2^40 × 2^40 overflows u64). + assert_eq!(checked_pixel_count(1 << 40, 1 << 40), None); + } + + #[test] + fn checked_pixel_count_over_cap_rejected() { + // MAX_SAMPLES = 2^30; one over is rejected. + assert_eq!(checked_pixel_count((1 << 30) + 1, 1), None); + assert_eq!(checked_pixel_count(1, (1 << 30) + 1), None); + } + + #[test] + fn checked_pixel_count_at_cap_accepted() { + assert_eq!(checked_pixel_count(1 << 30, 1), Some(1 << 30)); + assert_eq!(checked_pixel_count(1 << 15, 1 << 15), Some(1 << 30)); + } + + #[test] + fn checked_pixel_count_small_accepted() { + assert_eq!(checked_pixel_count(100, 100), Some(10_000)); + assert_eq!(checked_pixel_count(1, 1), Some(1)); + assert_eq!(checked_pixel_count(0, 100), None); + assert_eq!(checked_pixel_count(100, 0), None); + } +} diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 17032f694..a781151b4 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -192,8 +192,12 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack } comp::ADOBE_DEFLATE | comp::DEFLATE => { let bytes_per = if float { bits.div_ceil(8) as usize } else { 0 }; - let row_bytes = if float { cw * cpp * bytes_per } else { (cw * cpp * bits as usize).div_ceil(8) }; - let raw = inflate(src, row_bytes * ch)?; + // row bytes in u64: a wrapping `cw · cpp · …` in usize could come out small on 32-bit targets and slip past the gate + let row_bytes = if float { cw as u64 * cpp as u64 * bytes_per as u64 } else { (cw as u64 * cpp as u64 * bits as u64).div_ceil(8) }; + // the gate caps at MAX_SAMPLES (≤ 2^30), so the `usize` cast below is lossless on 32-bit targets too + let inflate_len = + crate::checked_pixel_count(row_bytes, ch as u64).ok_or_else(|| RawError::Corrupt("bad TIFF chunk dimensions".into()))?; + let raw = inflate(src, inflate_len as usize)?; unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor) } comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float), diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index a662799ae..3938fa50d 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -245,7 +245,9 @@ fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> O /// DSLR-A900 sample this is the whole strip (36 917 760 = 6080 × 4048 × 1.5) and it ends at the end of the file. fn is_packed12(info: &ImageInfo, strip_count: usize, strip_len: u64) -> bool { let (w, h) = (info.width as u64, info.height as u64); - info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && strip_len == w * h * 3 / 2 + // checked_mul chain: a wrapping `w · h · 3` must refuse the strip, not compare against a wrapped length + let packed_len = w.checked_mul(h).and_then(|n| n.checked_mul(3)).map(|n| n / 2); + info.bits() == 12 && strip_count == 1 && w % 2 == 0 && w > 0 && h > 0 && packed_len == Some(strip_len) } /// Unpack plain 12-bit samples stored least-significant-bit first: two pixels per three bytes, the first being @@ -621,9 +623,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?; let info = raw.image()?; let (w, h) = (info.width as usize, info.height as usize); - if w * h > crate::MAX_SAMPLES { - return Err(RawError::Limit("image too large")); - } + // the cap (≤ 2^30) keeps every downstream usize product (w·h·2, w·h·3/2, y·w) inside range on 32-bit targets + let samples = crate::checked_pixel_count(w as u64, h as u64).ok_or(RawError::Limit("image too large"))?; let bits = info.bits() as u32; let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR; let chunks = info.chunks(bytes.len() as u64); @@ -631,7 +632,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // The row-per-w-bytes (ARW2) layout stores exactly one byte per pixel in a single strip; all 108 such files in the test // archive measure exactly width x height. A200/A230/A350 files carry a strip 4-23 % larger than that (and A290/A390 one // smaller), which is a different packing, so they are refused rather than read with the wrong layout. - let one_byte_per_sample = chunks.len() == 1 && strip_len == (w * h) as u64; + let one_byte_per_sample = chunks.len() == 1 && strip_len == samples; let packed12 = info.compression == 32767 && is_packed12(&info, chunks.len(), strip_len); let (data, out_bits) = match info.compression { 7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14), @@ -642,7 +643,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { 32767 if one_byte_per_sample => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let curve = code_curve(&raw.u64s(TONE_CURVE).unwrap_or_else(|| vec![8000, 10400, 12900, 14100])); - let mut data = vec![0u16; w * h]; + let mut data = vec![0u16; samples as usize]; data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { let row = src.get(y * w..((y + 1) * w).min(src.len())).unwrap_or(&[]); if row.len() == w { @@ -659,7 +660,7 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { 32767 if packed12 => { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let row_bytes = w / 2 * 3; - let mut data = vec![0u16; w * h]; + let mut data = vec![0u16; samples as usize]; data.par_chunks_mut(w).enumerate().for_each(|(y, out)| { if let Some(row) = src.get(y * row_bytes..(y + 1) * row_bytes) { unpack_row12(row, out); diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 3fe173e09..4729d1235 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -161,7 +161,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let src = chunk_bytes(bytes, &chunk).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?; let (fw, fh, nc, prec) = ljpeg::frame_info(src)?; let frame = match mode { - Mode::Full => Some(ljpeg::decode(src, (fw * fh * nc).min(crate::MAX_SAMPLES))?), + Mode::Full => { + let total = (fw as u64) + .checked_mul(fh as u64) + .and_then(|v| v.checked_mul(nc as u64)) + .filter(|t| *t > 0 && *t <= crate::MAX_SAMPLES as u64) + .ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + Some(ljpeg::decode(src, total as usize)?) + } Mode::Header => None, }; let total = match &frame { @@ -186,7 +193,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { v.push(s[2] as usize); v } - None => vec![fw * nc], + None => { + let w = (fw as u64).checked_mul(nc as u64).ok_or_else(|| RawError::Corrupt("bad CR2 frame size".into()))?; + vec![w as usize] + } }; let width: usize = widths.iter().sum(); if width == 0 || total % width != 0 { diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index 853efdffe..13f63a4a3 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -366,6 +366,39 @@ fn split_dimension(length: usize, before: bool, after: bool) -> (usize, usize) { (length.div_ceil(2) + usize::from(after && length.is_multiple_of(2)), length / 2 + usize::from(before) + usize::from(after)) } +/// The width at which high-frequency band `index` (1–9, coarsest level first) is entropy coded, +/// given its synthesis width `width`. +/// +/// A tile with a neighbour on its right codes the vertical high-pass band of the level-1 and level-2 +/// low-pass columns (bands 8 and 5, whose low-pass columns the next level transforms again) at an odd +/// width: an even synthesis width gains one column, which synthesis does not use. Every other band, +/// including band 2 of the last level, is coded at its synthesis width. Measured on every C-RAW plane +/// that `tests/cr3_corpus.rs` decodes (CC0 raw.pixls.us files): two-tile files with tile widths 2816, +/// 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, R100, 250D, RP, R, 90D, M6 Mark II), where bands 5 +/// and 8 gain a column in left tiles exactly when the rule says so, and single-tile `0x100` and +/// `0x200` files, which never extend a band. In every one of those planes, each band's entropy stream +/// ends exactly at that width and at no other width from `width` to `width + 2`. The width used to be +/// probed by trying `width`, `width + 1` and `width + 2` in turn, but two widths can both consume a +/// stream exactly and give different coefficients (#899), so it is now taken from the geometry. +/// No public file has a middle tile (three or more C-RAW tiles); it is assumed to follow the left tile. +fn coded_band_width(index: usize, width: usize, edges: TileEdges) -> usize { + if edges.right && matches!(index, 5 | 8) { width | 1 } else { width } +} + +/// A high-frequency band entropy coded `coded_width` columns wide, cropped to its `width` synthesis columns. +fn coded_high_frequency_band(src: &[u8], width: usize, coded_width: usize, height: usize) -> Result> { + let values = high_frequency_band(src, coded_width, height)?; + if coded_width == width { + return Ok(values); + } + let mut cropped = Vec::new(); + cropped.try_reserve_exact(width.saturating_mul(height)).map_err(|_| RawError::Limit("CRX cropped band allocation"))?; + for row in values.chunks_exact(coded_width) { + cropped.extend_from_slice(row.get(..width).ok_or_else(|| corrupt("band crop outside row"))?); + } + Ok(cropped) +} + fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edges: TileEdges, qp: Option<&QpMap>) -> Result> { let mut dimensions = vec![(width, height)]; let mut high_dimensions = Vec::new(); @@ -383,32 +416,7 @@ fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edge let mut bands = Vec::with_capacity(10); for (i, (&(w, h), band)) in shapes.iter().zip(&plane.bands).enumerate() { let bytes = band_bytes(sample, band)?; - let mut data = if i == 0 { - lossless_plane(bytes, w, h)? - } else { - // Some CRX high-frequency bands retain a redundant right column - // beyond synthesis support (LH2 in the M50/R100 fixtures). Infer - // that small extension only from a complete, correctly aligned - // entropy stream, then crop each row to the synthesis support. - let mut decoded = None; - for extra in 0..=2 { - let coded_width = w.checked_add(extra).ok_or(RawError::Limit("CRX band width"))?; - if let Ok(values) = high_frequency_band(bytes, coded_width, h) { - if extra == 0 { - decoded = Some(values); - } else { - let mut cropped = Vec::new(); - cropped.try_reserve_exact(w * h).map_err(|_| RawError::Limit("CRX cropped band allocation"))?; - for row in values.chunks_exact(coded_width) { - cropped.extend_from_slice(row.get(..w).ok_or_else(|| corrupt("band crop outside row"))?); - } - decoded = Some(cropped); - } - break; - } - } - decoded.ok_or_else(|| RawError::Corrupt(format!("Canon CRX band {i}: unsupported geometry or corrupt entropy")))? - }; + let mut data = if i == 0 { lossless_plane(bytes, w, h)? } else { coded_high_frequency_band(bytes, w, coded_band_width(i, w, edges), h)? }; for (position, value) in data.iter_mut().enumerate() { let step = match band.quant { Quantization::Uniform(value) => quant_step(value, MAX_UNIFORM_QUANT)?, @@ -951,6 +959,56 @@ mod tests { } } + #[test] + fn coded_band_width_follows_the_measured_tile_geometry() { + let left_tile = TileEdges { right: true, ..TileEdges::default() }; + let right_tile = TileEdges { left: true, ..TileEdges::default() }; + let single = TileEdges::default(); + // Bands 5 and 8 of a left tile: an even width gains a column (RP 400 -> 401, R 862 -> 863, + // 90D / M6 Mark II 892 -> 893), an odd one does not (90D / M6 Mark II band 5 at 447). + for (band, width, coded) in [(5, 394, 395), (5, 400, 401), (5, 432, 433), (5, 447, 447), (8, 862, 863), (8, 892, 893), (8, 705, 705)] { + assert_eq!(coded_band_width(band, width, left_tile), coded, "band {band} at {width}"); + } + // The last level's low-pass columns are not transformed again: band 2 keeps an even width. + assert_eq!(coded_band_width(2, 198, left_tile), 198); + for band in [1, 3, 4, 6, 7, 9] { + assert_eq!(coded_band_width(band, 394, left_tile), 394, "band {band}"); + } + for band in 1..=9 { + assert_eq!(coded_band_width(band, 446, right_tile), 446, "band {band}"); + assert_eq!(coded_band_width(band, 446, single), 446, "band {band}"); + } + } + + #[test] + fn a_band_that_decodes_at_two_widths_uses_the_coded_width() { + // #899: entropy alignment alone does not identify the coded width. This 2-row stream is + // consumed exactly both 2 and 3 columns wide, with different retained coefficients. + let src = [0xc0, 0x0f]; + assert_eq!(high_frequency_band(&src, 2, 2).unwrap(), [0, 0, 5, -2]); + assert_eq!(high_frequency_band(&src, 3, 2).unwrap(), [0, 0, 5, 0, 0, 0]); + // The width probe took the first width that decoded (2). Band 8 of a left tile at an even + // width is coded one column wider, so the geometry selects 3 and crops it. + let coded = coded_band_width(8, 2, TileEdges { right: true, ..TileEdges::default() }); + assert_eq!(coded, 3); + assert_eq!(coded_high_frequency_band(&src, 2, coded, 2).unwrap(), [0, 0, 0, 0]); + // Elsewhere the same stream is read at its synthesis width. + assert_eq!(coded_high_frequency_band(&src, 2, coded_band_width(7, 2, TileEdges::default()), 2).unwrap(), [0, 0, 5, -2]); + // The #899 reproducer: an odd width is never extended, so the narrow reading is kept. + let src = [0x00, 0x33]; + assert_eq!(coded_band_width(5, 3, TileEdges { right: true, ..TileEdges::default() }), 3); + assert_eq!(coded_high_frequency_band(&src, 3, 3, 1).unwrap(), [5, 0, -1]); + assert_eq!(high_frequency_band(&src, 4, 1).unwrap(), [5, 0, 0, 0]); + } + + #[test] + fn a_band_that_fails_at_its_coded_width_is_an_error() { + // No fallback to another width: a stream that only fits 2 columns is corrupt where 3 are coded. + let src = [0xc0, 0x0f]; + assert!(high_frequency_band(&src, 4, 2).is_err()); + assert!(coded_high_frequency_band(&src, 3, 4, 2).is_err()); + } + #[test] fn craw_overshoot_at_clipped_highlights_is_clamped_to_the_bit_depth() { // As `three_level_craw_reconstructs_sensor_samples`, but each LL3 coefficient is +8200 diff --git a/crates/raw/tests/crx_properties.rs b/crates/raw/tests/crx_properties.rs index 32c9f65d3..804970de4 100644 --- a/crates/raw/tests/crx_properties.rs +++ b/crates/raw/tests/crx_properties.rs @@ -250,6 +250,7 @@ fn encode_qp_map(values: &[i32], width: usize, height: usize) -> Vec { // --- exact algebraic inverse of the crate's 5/3 synthesis (the forward transform under test) --- +#[derive(Clone)] struct Band { width: usize, height: usize, @@ -345,6 +346,17 @@ fn band_shapes(plane_width: usize, plane_height: usize, before: bool, after: boo [(lw3, lh3), (hw3, lh3), (lw3, hh3), (hw3, hh3), (hw2, lh2), (lw2, hh2), (hw2, hh2), (hw1, lh1), (lw1, hh1), (hw1, hh1)] } +/// The band as the camera codes it (crx.rs `coded_band_width`): with a tile on the right, bands 5 and 8 +/// are coded at an odd width, so an even band gains a column that synthesis ignores (zero here). +fn coded_band(band: &Band, index: usize, after: bool) -> Band { + if !(after && matches!(index, 5 | 8) && band.width.is_multiple_of(2)) { + return band.clone(); + } + let width = band.width + 1; + let data = band.data.chunks_exact(band.width).flat_map(|row| row.iter().copied().chain([0])).collect(); + Band { width, height: band.height, data } +} + fn encode_band(band: &Band, lowpass: bool) -> Vec { let mut bits = BitWriter::default(); if lowpass { @@ -568,7 +580,7 @@ fn encode_tile_planes( *plane_bytes = vec![encode_band(&band, true)]; } else { let bands = forward_bands(values, plane_w, plane_h, before, after, halo); - *plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(band, i == 0)).collect(); + *plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(&coded_band(band, i, after), i == 0)).collect(); } } encoded diff --git a/crates/segment/src/detector.rs b/crates/segment/src/detector.rs index 7c4eccbd3..c302aafe7 100644 --- a/crates/segment/src/detector.rs +++ b/crates/segment/src/detector.rs @@ -41,6 +41,9 @@ pub struct Output { pub boxes: Vec<[f32; 4]>, /// The text features `[1, 32, 256]` (tests). pub text: Tensor, + /// Semantic logits `[288, 288]` flattened: everything the prompt names, one mask without + /// boundaries between instances. + pub semantic: Vec, } struct EncoderLayer { @@ -91,6 +94,7 @@ pub struct Detector { pixel_convs: Vec, pixel_norms: Vec, instance_proj: Conv2d, + semantic_proj: Conv2d, mask_embedder: Vec, } @@ -155,6 +159,7 @@ impl Detector { pixel_convs: (0..3).map(|i| Ok(candle_nn::conv2d(HIDDEN, HIDDEN, 3, conv3, p.pp(format!("conv_layers.{i}")))?)).collect::>()?, pixel_norms: (0..3).map(|i| Ok(candle_nn::group_norm(8, HIDDEN, 1e-5, p.pp(format!("norms.{i}")))?)).collect::>()?, instance_proj: candle_nn::conv2d(HIDDEN, HIDDEN, 1, Conv2dConfig::default(), m.pp("instance_projection"))?, + semantic_proj: candle_nn::conv2d(HIDDEN, 1, 1, Conv2dConfig::default(), m.pp("semantic_projection"))?, mask_embedder: (0..3).map(|i| linear(HIDDEN, HIDDEN, m.pp(format!("mask_embedder.layers.{i}")))).collect::>()?, }) } @@ -268,6 +273,7 @@ impl Detector { prev = norm.forward(&conv.forward(&prev)?)?.relu()?; } let inst = self.instance_proj.forward(&prev)?; // [1, 256, 288, 288] + let semantic = to_vec(&self.semantic_proj.forward(&prev)?)?; let (_, c, mh, mw) = inst.dims4()?; let mut e = queries.clone(); for (i, l) in self.mask_embedder.iter().enumerate() { @@ -285,24 +291,98 @@ impl Detector { presence: to_vec(&presence)?.first().copied().unwrap_or(-10.0), boxes: bv.as_chunks::<4>().0.to_vec(), text, + semantic, }) } } +/// How far below 0 the strongest instance may be where it still takes its share of the semantic mask. +const NEAR: f32 = 4.0; + +/// Gives each of `instances` its share of `semantic` (everything the prompt names, one mask): where an +/// instance is the strongest and near (above −[`NEAR`]), the semantic logits replace its own (it keeps +/// its own where it is surer of itself). An instance's own logits stay close to 0 even well inside it +/// (a person comes out about 90 % selected) and both fade where two instances touch, which leaves a gap +/// between them; the semantic mask is surer inside and runs on across that boundary. Cells far from +/// every instance keep their logits, so things no instance found don't join one. +pub fn share(instances: &mut [Vec], semantic: &[f32]) { + let n = semantic.len(); + if instances.is_empty() || instances.iter().any(|l| l.len() != n) { + return; + } + for (i, s) in semantic.iter().enumerate() { + let Some((k, best)) = instances.iter().enumerate().map(|(k, l)| (k, l[i])).max_by(|a, b| a.1.total_cmp(&b.1)) else { return }; + if best > -NEAR { + let l = &mut instances[k][i]; + *l = if *l > 0.0 { l.max(*s) } else { *s }; + } + } +} + /// The instances of `out` scoring above `threshold` (sigmoid(logit) × sigmoid(presence)), -/// merged into one mask (the per-pixel maximum of their probabilities, as logits). +/// merged into one mask: each its share of the semantic mask ([`share`]), then the per-pixel +/// maximum of their probabilities, as logits. pub fn merge(out: &Output, threshold: f32) -> Option { let s = |x: f32| 1.0 / (1.0 + (-x).exp()); let presence = s(out.presence); let keep: Vec<(usize, f32)> = out.logits.iter().enumerate().map(|(i, l)| (i, s(*l) * presence)).filter(|(_, p)| *p > threshold).collect(); let best = keep.iter().map(|k| k.1).fold(0f32, f32::max); - let mut merged: Option> = None; - for (i, _) in &keep { - let m = out.masks.i(*i).ok().and_then(|t| to_vec(&t).ok())?; - merged = Some(match merged { - None => m, - Some(acc) => acc.iter().zip(&m).map(|(a, b)| a.max(*b)).collect(), - }); - } + let mut masks: Vec> = keep.iter().map(|(i, _)| out.masks.i(*i).ok().and_then(|t| to_vec(&t).ok())).collect::>()?; + share(&mut masks, &out.semantic); + let merged = masks.into_iter().reduce(|acc, m| acc.iter().zip(&m).map(|(a, b)| a.max(*b)).collect()); merged.map(|logits| Prediction { logits, score: best }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Two people side by side on a row of cells, each only weakly sure of themself and both fading where + /// they touch; the semantic mask is sure of both and runs across. Each takes the semantic logits where + /// it is the strongest instance, a third region nobody is near keeps its own. + #[test] + fn instances_share_the_semantic_mask() { + // cells: a (0..3), the seam (3), b (4..7), floor beside b (7), someone neither found (8), background + let a = vec![2.0, 2.5, 1.0, -0.5, -3.0, -5.0, -6.0, -3.5, -9.0, -9.0]; + let b = vec![-6.0, -5.0, -3.0, -1.0, 1.0, 2.5, 2.0, -3.0, -9.0, -9.0]; + let mut inst = vec![a, b]; + let semantic = [5.0, 6.0, 5.0, 4.0, 5.0, 6.0, 5.0, -7.0, 5.0, -8.0]; + share(&mut inst, &semantic); + assert_eq!(inst[0][..4], [5.0, 6.0, 5.0, 4.0], "a's share, the seam included (a is the stronger there)"); + assert_eq!(inst[1][4..7], [5.0, 6.0, 5.0], "b's share"); + assert_eq!(inst[0][4..7], [-3.0, -5.0, -6.0], "a stays out of b"); + assert_eq!(inst[1][7], -7.0, "the floor near b is surely background"); + assert_eq!([inst[0][8], inst[1][8]], [-9.0, -9.0], "someone neither instance is near joins neither"); + // an instance surer than the semantic mask keeps its own logits + let mut sure = vec![vec![9.0, 1.0]]; + share(&mut sure, &[5.0, 5.0]); + assert_eq!(sure[0], [9.0, 5.0]); + // mismatched sizes (no semantic mask) leave the instances alone + let mut none = vec![vec![1.0, 2.0]]; + share(&mut none, &[]); + assert_eq!(none[0], [1.0, 2.0]); + } + + /// Describe: the instances above the threshold, each with its share of the semantic mask, merged; + /// a query below the threshold adds nothing, and nothing found is `None`. + #[test] + fn describe_merges_the_shares_of_what_it_found() { + // two instances, weak inside and both fading where they touch (cell 2); a third query scores low + let masks = [[2.0f32, 1.0, -0.5, -3.0, -6.0], [-6.0, -3.0, -1.0, 1.0, 2.0], [-9.0, -9.0, -9.0, -9.0, 3.0]]; + let output = |logits: [f32; 3], semantic: &[f32]| Output { + masks: Tensor::from_vec(masks.concat(), (3, 1, 5), &Device::Cpu).unwrap(), + logits: logits.to_vec(), + presence: 8.0, + boxes: vec![[0.0; 4]; 3], + text: Tensor::zeros(1, candle_core::DType::F32, &Device::Cpu).unwrap(), + semantic: semantic.to_vec(), + }; + let semantic = [5.0, 6.0, 4.0, 6.0, 5.0]; + let merged = merge(&output([4.0, 4.0, -6.0], &semantic), 0.5).unwrap(); + assert_eq!(merged.logits, semantic, "sure inside both and across where they touch"); + // without a semantic mask: the per-pixel maximum of the instances, as before + let plain = merge(&output([4.0, 4.0, -6.0], &[]), 0.5).unwrap(); + assert_eq!(plain.logits, [2.0, 1.0, -0.5, 1.0, 2.0]); + assert!(merge(&output([-6.0; 3], &semantic), 0.5).is_none(), "nothing found"); + } +} diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index 6d488a919..6c24d0836 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -87,6 +87,11 @@ pub fn is_model_dir(dir: &Path) -> bool { dir.join(WEIGHTS_FILE).is_file() && dir.join("vocab.json").is_file() && dir.join("merges.txt").is_file() } +#[cfg(not(target_arch = "wasm32"))] +/// Whether [`best_device`] can be a GPU in this build (Metal on macOS). Where it can, a caller that +/// sees the model fail there can load it again with [`Sam3::load_cpu`]. +pub const GPU_BUILD: bool = cfg!(target_os = "macos"); + #[cfg(not(target_arch = "wasm32"))] /// The best device here: Metal on macOS (when a GPU is available), else the CPU. pub fn best_device() -> Device { @@ -134,6 +139,11 @@ impl Sam3 { Self::load_on(dir, best_device()) } + /// [`Sam3::load`] on the CPU, whatever GPU there is. + pub fn load_cpu(dir: &Path) -> Result { + Self::load_on(dir, Device::Cpu) + } + pub fn load_on(dir: &Path, device: Device) -> Result { if !is_model_dir(dir) { return Err(Error::Missing(dir.to_path_buf())); diff --git a/crates/segment/src/remote.rs b/crates/segment/src/remote.rs index cdee096fe..10f7b2931 100644 --- a/crates/segment/src/remote.rs +++ b/crates/segment/src/remote.rs @@ -108,10 +108,10 @@ impl Client { } } -pub fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> Result>, String> { +pub fn text_logits(model: &mut Sam3, enc: &mut Encoded, text: &str) -> crate::Result>> { let mut merged: Option> = None; for phrase in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) { - if let Some(p) = model.segment_text(enc, phrase, 0.5).map_err(|e| e.to_string())? { + if let Some(p) = model.segment_text(enc, phrase, 0.5)? { if let Some(m) = &mut merged { for (a, b) in m.iter_mut().zip(p.logits) { *a = a.max(b); @@ -130,7 +130,7 @@ fn predict(model: &mut Sam3, enc: &mut Encoded, operation: Operation) -> Result< model.prepare_clicks(enc).map_err(|e| e.to_string())?; Ok(None) } - Operation::Text(text) => text_logits(model, enc, &text), + Operation::Text(text) => text_logits(model, enc, &text).map_err(|e| e.to_string()), Operation::Clicks(points) => { let clicks: Vec<_> = points.into_iter().map(|p| Click { x: p[0], y: p[1], positive: p[2] == 1.0 }).collect(); Ok(Some(model.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?.logits)) diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/reference.rs index 315d36cd1..a2a110053 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/reference.rs @@ -93,5 +93,12 @@ fn matches_reference() { let want = vec(&r[&format!("det{i}_masks")].i((0, q)).unwrap()); assert!(rel_err("best mask", &ours, &want) < 2e-2); assert!(mask_disagreement(&ours, &want) < 2e-3); + // the semantic mask (all instances as one; Subject's people take their share of it); reference + // files made before it was written lack it + if let Some(want) = r.get(&format!("det{i}_semantic")) { + let want = vec(want); + assert!(rel_err("semantic mask", &out.semantic, &want) < 2e-2); + assert!(mask_disagreement(&out.semantic, &want) < 2e-3); + } } } diff --git a/crates/tiff/README.md b/crates/tiff/README.md index e6a771288..2436b0d31 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -29,9 +29,12 @@ later the TIFF/DNG exporters. The reader never panics on malformed input (proptests feed random and mutated files): offsets are bounds-checked; out-of-range or unknown-type entries are skipped; IFD loops are detected; IFD count, -nesting depth and entries per IFD are limited; a next-IFD pointer is followed only when every declared entry was -read, with the counts compared as 64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't -pass for a whole directory on a 32-bit target); the total bytes of decoded values are capped at +nesting depth and entries per IFD are limited; at most 65,536 directory entry slots +(`ParseOptions::max_entries_total`) are accepted across the main IFD chain and all child IFDs, even when tables +overlap at distinct offsets, and an IFD past that bound ends the chain or is skipped like any other bad IFD after +the first; a next-IFD pointer is followed only when every declared entry was read, with the counts compared as +64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't pass for a whole directory on a +32-bit target); the total bytes of decoded values are capped at `8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. ## Sources diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index 1d0b74d0d..2072c4936 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -11,8 +11,9 @@ //! either byte order, classic or BigTIFF. //! //! The reader never panics on malformed input: every offset is bounds-checked, IFD loops are -//! detected, nesting depth / IFD count are limited and the total amount of decoded value bytes is -//! capped relative to the input size (so a hostile file cannot make us allocate unboundedly). +//! detected, nesting depth / IFD count are limited, at most 65,536 directory entry slots are read +//! across the main chain and child IFDs, and decoded value bytes are capped relative to the input +//! size (so overlapping tables cannot multiply entry-vector allocations without bound). //! //! Sources: TIFF 6.0 specification (Adobe, 1992), TIFF Technical Note / supplements, the BigTIFF //! design notes (AWare Systems), Adobe DNG specification 1.7 (tag numbers), CIPA DC-008 (Exif 2.32). diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index 5f38dfad2..bd728ca9c 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -12,6 +12,15 @@ pub struct ParseOptions { pub max_depth: usize, /// Maximum number of entries read from one IFD. pub max_entries: usize, + /// Maximum total number of entries read across all IFDs (chain + children). + /// + /// Hostile files can reuse the same bytes for up to `max_ifds` tables of `max_entries` + /// entries each (overlapping IFD tables via SubIFD pointers shifted by one entry), which can + /// allocate millions of `Entry`s from a small input (~100 KB → ~400 MB). This hard cap bounds + /// that amplification regardless of entry size. 65 536 is generous for real files — a 50 MB + /// scanned TIFF with thousands of entries across chain + children stays well under it — yet + /// stops a small file from allocating hundreds of megabytes of `Entry`s. + pub max_entries_total: usize, /// Maximum total bytes of decoded values; `None` = `8 × input length + 1 MiB`. pub value_budget: Option, /// Follow `SubIFDs` / Exif / GPS / Interop pointers. @@ -22,7 +31,15 @@ pub struct ParseOptions { impl Default for ParseOptions { fn default() -> Self { - Self { max_ifds: 1024, max_depth: 10, max_entries: 8192, value_budget: None, follow_children: true, accept_raw_magic: true } + Self { + max_ifds: 1024, + max_depth: 10, + max_entries: 8192, + max_entries_total: 65536, + value_budget: None, + follow_children: true, + accept_raw_magic: true, + } } } @@ -195,12 +212,13 @@ struct Ctx<'a> { opts: &'a ParseOptions, visited: HashSet, budget: u64, + entries_total: usize, } impl<'a> Ctx<'a> { fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self { let budget = opts.budget_for(data.len()); - Self { data, order, big, opts, visited: HashSet::new(), budget } + Self { data, order, big, opts, visited: HashSet::new(), budget, entries_total: 0 } } fn read_off(&self, pos: usize) -> Option { @@ -219,6 +237,12 @@ impl<'a> Ctx<'a> { return Err(TiffError::Invalid(format!("IFD loop at {offset}"))); } let dir = directory_entries(self.data, offset, self.order, self.big, self.opts.max_entries)?; + // Bound total entries across the whole parse so overlapping IFD tables (SubIFD pointers + // shifted by one entry) cannot allocate millions of `Entry`s from a small input. + self.entries_total = self.entries_total.saturating_add(dir.read); + if self.entries_total > self.opts.max_entries_total { + return Err(TiffError::Limit("entry count")); + } let mut entries = Vec::with_capacity(dir.read); for p in dir.positions() { if let Some(e) = self.entry(p, base) { @@ -405,4 +429,89 @@ mod tests { assert_eq!(tiff.ifds.len(), 1); assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); } + #[test] + fn overlapping_ifd_tables_hit_entry_cap_instead_of_amplifying() { + // classic TIFF; IFDs at 8, 20, 32, ... each declaring 8192 entries whose tables overlap + // (next table starts one entry after the previous table's start). Without the cap, a single + // `Tiff::parse` would run `Vec::with_capacity(8192)` per IFD inside one `Ctx`, amplifying a + // small file into millions of `Entry`s; the per-`Ctx` `max_entries_total` cap rejects the + // 9th IFD before any allocation. + let mut data = vec![0u8; 200_000]; + data[0..2].copy_from_slice(b"II"); + data[2..4].copy_from_slice(&42u16.to_le_bytes()); + data[4..8].copy_from_slice(&8u32.to_le_bytes()); // first IFD at 8 + + for k in 0..9u64 { + let ifd_off = (8 + 12 * k) as usize; + data[ifd_off..ifd_off + 2].copy_from_slice(&8192u16.to_le_bytes()); // count + let table = ifd_off + 2; + let ptr = table + 8192 * 12; // next-IFD pointer lives right after the entries + let next = if k + 1 < 9 { (8 + 12 * (k + 1)) as u32 } else { 0 }; + data[ptr..ptr + 4].copy_from_slice(&next.to_le_bytes()); + } + + let tiff = Tiff::parse(&data).unwrap(); + assert_eq!(tiff.ifds.len(), 8, "8 IFDs parsed, 9th rejected by max_entries_total before allocation"); + } + + #[test] + fn a_legitimate_multi_ifd_file_still_parses_under_the_entry_cap() { + // classic TIFF: IFD0 @8 (3 entries: value + Exif + GPS child pointers) -> IFD1 @58 chain, + // plus Exif child @60 and GPS child @90. Total entries = 6, far under max_entries_total. + let mut data = vec![0u8; 128]; + data[0..2].copy_from_slice(b"II"); + data[2..4].copy_from_slice(&42u16.to_le_bytes()); + data[4..8].copy_from_slice(&8u32.to_le_bytes()); // first IFD at 8 + + // IFD0 @8: count 3, table @10, next-IFD @46 -> IFD1 @58 + data[8..10].copy_from_slice(&3u16.to_le_bytes()); + // entry0 @10: tag 0x0100, SHORT, count 1, value 8 (inline @18..22) + data[10..12].copy_from_slice(&0x0100u16.to_le_bytes()); + data[12..14].copy_from_slice(&3u16.to_le_bytes()); + data[14..18].copy_from_slice(&1u32.to_le_bytes()); + data[18..22].copy_from_slice(&8u32.to_le_bytes()); + // entry1 @22: tag 0x8769 ExifIFD, LONG, count 1, value 60 (inline @30..34) + data[22..24].copy_from_slice(&0x8769u16.to_le_bytes()); + data[24..26].copy_from_slice(&4u16.to_le_bytes()); + data[26..30].copy_from_slice(&1u32.to_le_bytes()); + data[30..34].copy_from_slice(&60u32.to_le_bytes()); + // entry2 @34: tag 0x8825 GPSIFD, LONG, count 1, value 90 (inline @42..46) + data[34..36].copy_from_slice(&0x8825u16.to_le_bytes()); + data[36..38].copy_from_slice(&4u16.to_le_bytes()); + data[38..42].copy_from_slice(&1u32.to_le_bytes()); + data[42..46].copy_from_slice(&90u32.to_le_bytes()); + data[46..50].copy_from_slice(&58u32.to_le_bytes()); // next IFD + + // IFD1 @58: count 1, table @60, next 0 + data[58..60].copy_from_slice(&1u16.to_le_bytes()); + data[60..62].copy_from_slice(&0x0100u16.to_le_bytes()); + data[62..64].copy_from_slice(&3u16.to_le_bytes()); + data[64..68].copy_from_slice(&1u32.to_le_bytes()); + data[68..72].copy_from_slice(&8u32.to_le_bytes()); + data[72..76].copy_from_slice(&0u32.to_le_bytes()); + + // Exif child IFD @60: count 1, entry tag 0x9000 value 1, next 0 + data[60..62].copy_from_slice(&1u16.to_le_bytes()); + data[62..64].copy_from_slice(&0x9000u16.to_le_bytes()); + data[64..66].copy_from_slice(&3u16.to_le_bytes()); + data[66..70].copy_from_slice(&1u32.to_le_bytes()); + data[70..74].copy_from_slice(&1u32.to_le_bytes()); + data[74..78].copy_from_slice(&0u32.to_le_bytes()); + + // GPS child IFD @90: count 1, entry tag 0 value 1, next 0 + data[90..92].copy_from_slice(&1u16.to_le_bytes()); + data[92..94].copy_from_slice(&0u16.to_le_bytes()); + data[94..96].copy_from_slice(&3u16.to_le_bytes()); + data[96..100].copy_from_slice(&1u32.to_le_bytes()); + data[100..104].copy_from_slice(&1u32.to_le_bytes()); + data[104..108].copy_from_slice(&0u32.to_le_bytes()); + + let tiff = Tiff::parse(&data).unwrap(); + assert_eq!(tiff.ifds.len(), 2, "chain IFDs parse fully"); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + let exif = tiff.ifds[0].exif.as_ref().expect("Exif child present"); + assert_eq!(exif.u64(0x9000), Some(1)); + let gps = tiff.ifds[0].gps.as_ref().expect("GPS child present"); + assert_eq!(gps.u64(0x0000), Some(1)); + } } diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 8c3c97427..bbfa7246d 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -248,50 +248,51 @@ "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ již obsahuje „{name}“. Sloučit obě slova?", "• {assumption}": "• {assumption}", "… and {more} more": "… a dalších {more}", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - "Create the keyword “{name}”": "Create the keyword “{name}”", - "new, inside {parent}": "new, inside {parent}", - "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", - " in {g} group{}": " in {g} group{}", - " · kept, not applied: {}": " · kept, not applied: {}", - " — kept but not applied: {}": " — kept but not applied: {}", - "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", - "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", - "Delete these {n} effect presets?": "Delete these {n} effect presets?", - "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", - "Effect: {name}": "Effect: {name}", - "Exported {} effect preset{}": "Exported {} effect preset{}", - "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", - "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", - "{n} preset{}": "{n} preset{}", - "{count} more warnings.": "{count} more warnings.", - "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", - "Applied {name}": "Applied {name}", - "Delete “{name}”?": "Delete “{name}”?", - "Updated {name}": "Updated {name}", - "Exported to {path}": "Exported to {path}", - "Saved template {name}": "Saved template {name}", - "Imported {name}": "Imported {name}", - "Skipped {problem}": "Skipped {problem}", - "…and {more} more": "…and {more} more", - "Template preview: {name}": "Template preview: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", - "{n} pages": "{n} pages", - "{n} JPEG files": "{n} JPEG files", - "{s} s of AV1 video": "{s} s of AV1 video", - "Slideshow exported: {what}": "Slideshow exported: {what}", - " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", - "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", - "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nepodařilo se zapsat přidružené soubory XMP u fotografií v počtu {n} (není jejich složka offline?). LightCraft to bude zkoušet dál.", + "Create the keyword “{name}”": "Vytvořit klíčové slovo „{name}“", + "new, inside {parent}": "nové, pod {parent}", + "Keywords of {n} selected photos": "Klíčová slova vybraných fotografií (počet: {n})", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Klíčové slovo „{name}“ bylo odebráno z fotografií v počtu {n}. Úpravy → Zpět jej vrátí.", + " in {g} group{}": " ve skupinách: {g}{:.0}", + " · kept, not applied: {}": " · zachováno, nepoužito: {}", + " — kept but not applied: {}": " — zachováno, ale nepoužito: {}", + "Apply to “{mask}” on Selected Photos": "Použít na masku „{mask}“ u vybraných fotografií", + "Delete the effect preset “{name}”?": "Odstranit přednastavení efektu „{name}“?", + "Delete these {n} effect presets?": "Odstranit tato přednastavení efektů (počet: {n})?", + "Delete {n} effect preset{}? This can't be undone.": "Odstranit přednastavení efektů v počtu {n}{:.0}? Tuto akci nelze vrátit zpět.", + "Effect: {name}": "Efekt: {name}", + "Exported {} effect preset{}": "Exportováno přednastavení efektů: {0}{1:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Importováno efektů masek: {e}{:.0} (Maskování → Efekt)", + "Update “{name}” with Current Settings": "Aktualizovat „{name}“ podle aktuálního nastavení", + "{n} preset{}": "Přednastavení: {n}{:.0}", + "{count} more warnings.": "Další varování: {count}.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Neimportované obrázky katalogu: {skipped}. Jejich záznamy v katalogu jsou neúplné.", + "{photos} catalog records processed; {collections} collections.": "Zpracováno záznamů katalogu: {photos}; kolekcí: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Chybějící originály: {missing}. Jejich záznamy jsou v katalogu dostupné; použijte Najít chybějící fotografie a znovu je propojte.", + "Applied {name}": "Použito: {name}", + "Delete “{name}”?": "Odstranit „{name}“?", + "Updated {name}": "Aktualizováno: {name}", + "Exported to {path}": "Exportováno do {path}", + "Saved template {name}": "Uložena šablona {name}", + "Imported {name}": "Importováno: {name}", + "Skipped {problem}": "Přeskočeno: {problem}", + "…and {more} more": "…a další: {more}", + "Template preview: {name}": "Náhled šablony: {name}", + "{n} photos in view · {slides} slides · {time}": "Fotografie v zobrazení: {n} · snímky: {slides} · {time}", + "{n} pages": "Stránky: {n}", + "{n} JPEG files": "Soubory JPEG: {n}", + "{s} s of AV1 video": "Video AV1: {s} s", + "Slideshow exported: {what}": "Prezentace byla exportována: {what}", + " · {n} skipped (could not be rendered)": " · přeskočeno: {n} (nepodařilo se vykreslit)", + "{n} raws can now be developed from their raw data": "Soubory RAW, které lze nyní vyvolat z dat RAW: {n}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft se neočekávaně ukončil: {e}\n\nPodrobnosti najdete v souboru protokolu: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft se neočekávaně ukončil: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Přejmenuje obličeje v celé knihovně (počet: {n}{:.0}). Soubory se nezmění.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "Osoba „{to}“ již existuje. Sloučením vznikne jedna osoba; příkaz Zpět je znovu oddělí.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Přidáno sloučení {what} v rozlišení {mp}: plné rozlišení {full} vyžaduje více paměti, než má tento počítač", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Přidáno sloučení {what} v rozlišení {mp}, což je nejvyšší možné rozlišení panoramatu (plné rozlišení je {full})", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Barevný prostor: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "Vybrané fotografie: {n} · stránky: {pages}", "Already in “{}”": "Již v „{}“", "Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?", diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json index 1e84313ae..1ae372144 100644 --- a/crates/ui-egui/locales/cs.json +++ b/crates/ui-egui/locales/cs.json @@ -11,6 +11,7 @@ ", rejected": ", odmítnuto", "0: nothing is rejected.": "0: nic se neodmítá.", "1 day": "1 den", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuální úpravy · přizpůsobit bez ořezu", "2026-04 or 2026-04-12": "2026-04 nebo 2026-04-12", "= exactly": "= přesně", "A blank template keeps the original names.": "Prázdná šablona zachová původní názvy.", @@ -19,6 +20,7 @@ "A folder is being synchronized": "Probíhá synchronizace složky", "A keyword set needs a name": "Sada klíčových slov musí mít název", "A one-time download of about": "Jednorázové stažení přibližně", + "A pen's pressure sets the size and flow of the brush": "Přítlak pera určuje velikost a tok štětce", "AI Denoise": "Redukce šumu pomocí AI", "AI Denoise Model": "Model pro redukci šumu pomocí AI", "AI Denoise is enabled on the requesting photo": "Redukce šumu pomocí AI je u požadované fotografie zapnutá", @@ -53,6 +55,9 @@ "Add to Favorites": "Přidat do oblíbených", "Add to Local": "Přidat do místních souborů", "Add to My Photos": "Přidat do mých fotografií", + "Can't be moved here": "Sem nelze přesunout", + "This row covers more than one disk: drop on a folder inside it": "Tento řádek zahrnuje více disků: přetáhněte položky na některou ze složek v něm", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Místní fotografie zatím nejsou v knihovně: nejprve použijte Přidat do mých fotografií a pak je přesuňte", "Add to My Photos First": "Nejprve přidat do mých fotografií", "Add to Selected Photos": "Přidat k vybraným fotografiím", "Add to Target Album": "Přidat do cílového alba", @@ -66,6 +71,108 @@ "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nebyl nalezen grafický procesor: LightCraft vykresluje na CPU, což je mnohem pomalejší. Podrobnosti najdete v nabídce Nápověda → Informace o systému.", "On NixOS, start it from `nix develop`.": "V NixOS ho spusťte z `nix develop`.", "Files with the same content as another file in this import: only the first is imported.": "Soubory se stejným obsahem jako jiný soubor v tomto importu: importuje se jen první z nich.", + "Lightroom Catalog Import": "Import katalogu Lightroom", + "Destination library": "Cílová knihovna", + "Show in Library": "Zobrazit v knihovně", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Katalogy Lightroom se importují do otevřené knihovny; samostatná složka katalogu se nevytváří.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Tyto fotografie jsou pouze v paměti. Chcete-li je uložit, otevřete knihovnu na disku a importujte katalog znovu.", + "Playback": "Přehrávání", + "Layout": "Rozložení", + "Link all margins": "Propojit všechny okraje", + "Margins": "Okraje", + "Left": "Vlevo", + "Right": "Vpravo", + "Top": "Nahoře", + "Bottom": "Dole", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Okraje se zadávají jako podíl kratší strany snímku, takže snímky vypadají na každé obrazovce stejně.", + "Show guides": "Zobrazit vodítka", + "Aspect preview": "Poměr stran náhledu", + "Zoom to fill frame": "Přiblížit pro vyplnění rámečku", + "Stroke border": "Obrysový rámeček", + "Colour": "Barva", + "Cast shadow": "Vržený stín", + "Offset": "Posun", + "Backdrop": "Pozadí", + "Background colour": "Barva pozadí", + "Colour wash": "Barevný přechod", + "Background image": "Obrázek na pozadí", + "(not in the library)": "(není v knihovně)", + "Use selected photo": "Použít vybranou fotografii", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Vyberte fotografii a zapněte tuto volbu, aby vyplnila pozadí snímku za každou fotografií.", + "Reset layout": "Obnovit rozložení", + "Select a photo to preview its slide": "Vyberte fotografii pro náhled jejího snímku", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Vybraná fotografie jako snímek. Přehrávání vyplní obrazovku; poměr stran náhledu mění pouze tento náhled.", + "Overlays": "Překrytí", + "Identity plate": "Označení autora", + "Your name or studio": "Vaše jméno nebo studio", + "Anchored to": "Ukotveno k", + "Slide": "Snímek", + "Render behind image": "Vykreslit za obrázkem", + "Rating stars": "Hvězdičky hodnocení", + "Text overlays": "Textová překrytí", + "Add text overlay": "Přidat textové překrytí", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Značky: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Ostatní text se zobrazí tak, jak jej zadáte.", + "Use export watermark": "Použít exportní vodoznak", + "The export watermark, drawn on the photo and sized relative to it.": "Exportní vodoznak, vykreslený na fotografii s velikostí vztaženou k ní.", + "Titles": "Titulky", + "Intro screen": "Úvodní obrazovka", + "Ending screen": "Závěrečná obrazovka", + "Shows": "Obsah", + "Colour only": "Pouze barva", + "Override colour": "Vlastní barva", + "Own duration (off: the slide duration)": "Vlastní délka (vypnuto: délka snímku)", + "Duration": "Délka", + "Title text": "Text titulku", + "Intro": "Úvod", + "Ending": "Závěr", + "End": "Závěr", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Úvod se přehraje jednou před první fotografií; závěr po poslední fotografii, pokud je vypnuto opakování. Označení autora se přebírá z nastavení Překrytí. Titulky se prolínají jako snímky a náhodné pořadí je nikdy nepřesouvá.", + "This screen is off: turn it on under Titles to play it.": "Tato obrazovka je vypnutá: chcete-li ji přehrát, zapněte ji v části Titulky.", + "The intro screen, before the first photo.": "Úvodní obrazovka, před první fotografií.", + "The ending screen, after the last photo.": "Závěrečná obrazovka, po poslední fotografii.", + "Templates": "Šablony", + "Built-in": "Vestavěné", + "Your templates": "Vaše šablony", + "None yet: save the current settings as a template below.": "Zatím žádné: níže uložte aktuální nastavení jako šablonu.", + "Before the last template": "Před použitím poslední šablony", + "Previous settings": "Předchozí nastavení", + "New template name": "Název nové šablony", + "Save as Template": "Uložit jako šablonu", + "Import…": "Importovat…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Vlastní šablony vyžadují složku nastavení, kterou toto sestavení nemá; vestavěné šablony fungují.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Kliknutím šablonu použijete; najetím ukazatele zobrazíte její náhled na vybrané fotografii. Vlastní šablonu můžete kliknutím pravým tlačítkem aktualizovat, přejmenovat, exportovat nebo odstranit.", + "Import Slideshow Templates": "Importovat šablony prezentace", + "Export Slideshow Template": "Exportovat šablonu prezentace", + "Slideshow Templates": "Šablony prezentace", + "Plain Black": "Prostá černá", + "Gallery Wall": "Stěna galerie", + "Edge to Edge": "Od kraje ke kraji", + "Cinema Wide": "Širokoúhlé kino", + "Shooting Data": "Údaje o pořízení", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Každá fotografie co největší na černém pozadí, s krátkým prolnutím. Nic se přes ni nekreslí.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Světle šedá stěna s bílou paspartou a jemným stínem; popisek a hodnocení pod fotografií.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Každá fotografie oříznutá tak, aby vyplnila celou obrazovku, s pomalým prolnutím.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Rám 16:9 s černými pruhy nahoře a dole a titulem v rohu; přechody přes černou a na konci závěrečná obrazovka.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Tmavé břidlicové pozadí; pod každou fotografií expozice, ISO a ohnisková vzdálenost a dále fotoaparát a objektiv.", + "Exporting slideshow": "Exportování prezentace", + "Export Slideshow…": "Exportovat prezentaci…", + "Export Slideshow": "Exportovat prezentaci", + "Export Slideshow: folder for the JPEG files": "Exportovat prezentaci: složka pro soubory JPEG", + "Frame rate": "Snímková frekvence", + "Container": "Kontejner", + "Export PDF…": "Exportovat PDF…", + "Export JPEG…": "Exportovat JPEG…", + "Export Video…": "Exportovat video…", + "Automatically show full screen": "Automaticky zobrazit na celou obrazovku", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Jeden snímek na stránku (delší strana v pixelech = body), s délkou zobrazení snímku a prolnutím pro režim prezentace v prohlížeči PDF.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Číslované soubory JPEG (name-001.jpg…) v nové složce, kterou pojmenujete v dalším kroku.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Video AV1, každý snímek se zobrazuje po nastavenou dobu včetně prolnutí. Bez zvuku: hudba se zatím neexportuje.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Snímky používají rozložení, překrytí a úvodní i závěrečnou obrazovku prezentace a řadí se podle aktuálního zobrazení.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Fotografie v aktuálním zobrazení jako snímky s výše uvedeným nastavením. Průběh se zobrazuje v seznamu úloh na pozadí.", + "Checking raw files": "Kontrola souborů RAW", + "1 raw can now be developed from its raw data": "1 soubor RAW lze nyní vyvolat z dat RAW", + "This raw is now developed from its raw data": "Tento soubor RAW se nyní vyvolává z dat RAW", + "This raw variant still can't be decoded": "Tuto variantu RAW stále nelze dekódovat", "Adding to Apple Photos…": "Přidávání do Apple Photos…", "Advanced search…": "Rozšířené hledání…", "After": "Po", @@ -324,7 +431,14 @@ "Concrete Cool": "Chladný beton", "Confirm before deleting": "Potvrdit před odstraněním", "Confirm before moving photos to Recently Deleted": "Potvrdit před přesunem fotografií do nedávno odstraněných", + "Move Photos": "Přesunout fotografie", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Jejich soubory a přidružené soubory XMP se přesunou na disku. Úpravy → Zpět je přesune zpět.", + "Don't ask again": "Znovu se neptat", + "Another library was opened since the photos were dropped: nothing was moved": "Od přetažení fotografií byla otevřena jiná knihovna: nic nebylo přesunuto", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Znovu zapnete v Nastavení → Potvrdit před přesunem fotografií do jiné složky na disku", + "Confirm before moving photos to another folder on disk": "Potvrdit před přesunem fotografií do jiné složky na disku", "Constrain Crop": "Omezit oříznutí", + "Contact Sheet PDF": "Přehled snímků PDF", "Content-Aware": "Podle obsahu", "Continue": "Pokračovat", "Continue Without Saving": "Pokračovat bez uložení", @@ -603,6 +717,7 @@ "File name": "Název výstupního souboru", "File types:": "Typy souborů:", "Filename": "Jméno souboru", + "Filename captions": "Popisky s názvy souborů", "Fill": "Vyplnit", "Fill Edges": "Vyplnit okraje", "Fill Location": "Doplnit místo", @@ -671,6 +786,7 @@ "Friday": "Pátek", "Full": "Plné", "Full Screen Preview": "Náhled na celé obrazovce", + "Hold View Until Refreshed": "Ponechat zobrazení do obnovení", "Full screen mode": "Režim celé obrazovky", "Full size": "Plná velikost", "GPS Track Log": "Záznam trasy GPS", @@ -728,6 +844,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "Panoramata HDR", "HDR with Last Settings": "HDR s posledním nastavením", + "HDR for Each Bracket Set": "Sloučit každou expoziční řadu do HDR", "HDR…": "HDR…", "Has Edits": "Obsahuje úpravy", "Has GPS": "Obsahuje GPS", @@ -800,6 +917,7 @@ "In memory — nothing is saved": "V paměti — nic není uloženo", "In use": "Používané", "In-memory session: these settings last until LightCraft quits.": "Relace v paměti: tato nastavení platí do ukončení LightCraft.", + "Include selected photos": "Zahrnout vybrané fotografie", "Include on Export": "Zahrnout do exportu", "Include subfolders": "Zahrnout podsložky", "Increase Brush Feather": "Zvětšit prolnutí štětce", @@ -1096,12 +1214,26 @@ "No edits yet.": "Zatím žádné úpravy.", "No exposure information": "Žádné informace o expozici", "No filter presets yet": "Zatím žádná přednastavení filtrů", + "No lens data embedded in this file.": "V tomto souboru nejsou vložena data objektivu.", "No folders to browse": "Žádné složky k procházení", "No imported folders yet": "Zatím žádné importované složky", "No keywords are exported": "Neexportují se žádná klíčová slova", "No keywords match": "Žádné klíčové slovo neodpovídá", "No keywords yet": "Zatím žádná klíčová slova", + "Show fewer": "Zobrazit méně", + "Whole names": "Úplné názvy", + "Grouped by parent": "Seskupené podle nadřazeného slova", + "Keywords of the selection": "Klíčová slova výběru", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Seskupené: klíčová slova se stejným nadřazeným slovem sdílejí řádek s jeho názvem; dlouhé seznamy a návrhy se zkracují.", + "Show every keyword of the selected photos": "Zobrazit všechna klíčová slova vybraných fotografií", + "new keyword": "nové klíčové slovo", + "Return creates this keyword and gives it to the selected photos": "Klávesou Return vytvoříte toto klíčové slovo a přiřadíte je vybraným fotografiím", + "Separate several keywords with commas. Return gives them to the selected photos.": "Více klíčových slov oddělte čárkami. Klávesou Return je přiřadíte vybraným fotografiím.", "No lens data embedded in this file (DNG lens opcodes only).": "V tomto souboru nejsou vložena data objektivu (pouze operační kódy objektivu DNG).", + "Export to": "Exportovat do", + "Specific folder": "Konkrétní složka", + "Same folder as original photo": "Stejná složka jako původní fotografie", + "Tidying the thumbnail cache": "Úklid mezipaměti miniatur", "No masks yet. Choose a mask type above.": "Zatím žádné masky. Vyberte typ masky nahoře.", "No matching commands": "Žádné odpovídající příkazy", "No matching photos": "Žádné odpovídající fotografie", @@ -1113,6 +1245,11 @@ "No photo has it; the keywords below it go too.": "Není přiřazeno žádné fotografii; odstraní se i podřízená klíčová slova.", "No photo selected": "Není vybrána fotografie", "No photos": "Žádné fotografie", + "Your library is empty": "Vaše knihovna je prázdná", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Zde se zobrazí importované fotografie. Nic se nekopíruje ani nepřesouvá, pokud to sami nezvolíte.", + "or drop photos and folders into this window": "nebo do tohoto okna přetáhněte fotografie a složky", + "Import photos to get started": "Začněte importem fotografií", + "or drop photos into this window": "nebo do tohoto okna přetáhněte fotografie", "No photos found": "Nebyly nalezeny fotografie", "No photos in this folder": "V této složce nejsou fotografie", "No photos match the active filters": "Žádné fotografie neodpovídají aktivním filtrům", @@ -1203,6 +1340,7 @@ "Paste from Previous": "Vložit z předchozí fotografie", "Paste pages": "Vložit stránky", "Pastel Haze": "Pastelový opar", + "Pen Pressure": "Přítlak pera", "Pending photo action cancelled": "Čekající akce s fotografií byla zrušena", "Pending photo action cancelled because its library or photo changed": "Čekající akce byla zrušena, protože se změnila knihovna nebo fotografie", "People": "Lidé", @@ -1223,6 +1361,7 @@ "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Fotografie → Upravit v externím editoru (⇧⌘E) vykreslí 16bitovou kopii TIFF, přidá ji do sady s originálem a otevře v této aplikaci (název aplikace v macOS, jinde cesta k programu).", "PhotoCraft": "PhotoCraft", "Photos": "Fotografie", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotografie upravené v HDR: JPEG s mapou zisku HDR (správné zobrazení všude, jasnější světla na displejích HDR), AVIF jako 10bitový, PNG a JPEG XL jako 16bitové Rec. 2020 PQ; TIFF vyžaduje 32bitová čísla s plovoucí řádovou čárkou", "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Fotografie se zobrazují přes ICC profil monitoru, aby byly barvy správné na kalibrovaných displejích i displejích se širokým gamutem. Detail využívá celý gamut displeje. Histogram, mezipaměti náhledů ani export se nemění.", "Photos per bracket (0 = auto)": "Fotografií na expoziční řadu (0 = automaticky)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotografie prohlížené v místních souborech, které nebyly přidány ani změněny, se odeberou z katalogu, pokud se jejich složka po tuto dobu neprocházela (kontroluje se při otevření knihovny). Soubory zůstanou na disku; procházením složky se znovu zobrazí.", @@ -1368,6 +1507,7 @@ "Refining the mask's detail…": "Zpřesňování detailů masky…", "Refresh Source": "Obnovit zdroj", "Refresh Source (/)": "Obnovit zdroj (/)", + "Refresh View": "Obnovit zobrazení", "Reject": "Odmítnout", "Reject below focus": "Odmítnout pod limitem zaostření", "Rejected": "Odmítnuto", @@ -1440,7 +1580,38 @@ "Reset Sidebar Sections": "Obnovit sekce postranního panelu", "Reset Tone Curve": "Obnovit tónovou křivku", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Obnoví všechny křivky: bodové (všechny kanály) i parametrickou (dvojklik na kanál obnoví pouze tento kanál)", + "Reset all edits": "Obnovit všechny úpravy", "Reset all edits (Cmd+Shift+R)": "Obnovit všechny úpravy (Cmd+Shift+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Inteligentní alba se aktualizují automaticky: změňte jejich pravidla nebo použijte Fotografie → Odstranit fotografii", + "LightCraft stopped": "LightCraft se neočekávaně ukončil", + "Show Focus Point": "Zobrazit zaostřovací bod", + "Zoom to Focus Point": "Přiblížit na zaostřovací bod", + "Focus": "Zaostření", + "Focus point": "Zaostřovací bod", + "Wide": "Široká oblast", + "Center": "Střed", + "Flexible Spot": "Pohyblivý bod", + "Zone": "Zóna", + "Expanded Flexible Spot": "Rozšířený pohyblivý bod", + "Custom AF Area": "Vlastní oblast AF", + "Face tracking": "Sledování obličeje", + "Lock On AF": "AF se sledováním objektu", + "Multi": "Vícebodové", + "Spot": "Bod", + "Tracking": "Sledování", + "Face Tracking": "Sledování obličeje", + "Animal Eye Tracking": "Sledování zvířecích očí", + "Human Eye Tracking": "Sledování lidských očí", + "Read Focus Points": "Načíst zaostřovací body", + "Reading focus points": "Načítání zaostřovacích bodů", + "Run face models on the CPU only": "Spouštět modely rozpoznávání obličejů pouze na CPU", + "Detection": "Detekce", + "Recognition": "Rozpoznávání", + "Not loaded yet": "Zatím nenačteno", + "Checking the GPU…": "Kontrola GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Model rozpoznávání obličejů používá GPU pouze tehdy, když kontrola prokáže, že na něm dosahuje stejných výsledků rychleji.", + "Checking the GPU for faces": "Kontrola GPU pro rozpoznávání obličejů", "Reshuffle": "Znovu promíchat", "Resize Face Box": "Změnit velikost rámečku obličeje", "Resize panel window": "Změnit velikost okna panelu", @@ -1611,6 +1782,7 @@ "Show overlay (O)": "Zobrazit překrytí (O)", "Show ratings, flags and edit badges": "Zobrazit hodnocení, příznaky a odznaky úprav", "Show the Navigator while zoomed in": "Zobrazit navigátor při přiblížení", + "Show the import review when a camera or card is connected": "Zobrazit kontrolu importu při připojení fotoaparátu nebo karty", "Show the template tags; click one to insert it": "Zobrazit značky šablony; kliknutím jednu vložíte", "Show/Hide Mask": "Zobrazit/Skrýt masku", "Show/hide My Photos panel": "Zobrazit/Skrýt panel mých fotografií", @@ -1644,6 +1816,7 @@ "Soft Pastel": "Jemné pastelové", "Soft Proofing": "Měkký nátisk", "Soft Skin": "Jemná pleť", + "Solo Mode": "Režim jedné sekce", "Some rules need fixing (Edit Smart Album…):": "Některá pravidla je třeba opravit (Upravit inteligentní album…):", "Sort": "Seřadit", "Sort Albums A–Z": "Seřadit alba A–Z", @@ -1879,6 +2052,7 @@ "Will be restored": "Bude obnoveno", "Window": "Okno", "Winter Blue": "Zimní modř", + "Wrote the XMP sidecars that were waiting": "Čekající přidružené soubory XMP byly zapsány", "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funguje se soubory RAW z fotoaparátů se standardním snímačem (Bayer). Ostatní soubory si zachovají běžnou redukci šumu.", "XMP Sidecar Preferences": "Předvolby přidružených souborů XMP", "XMP sidecars": "Přidružené soubory XMP", @@ -2052,6 +2226,8 @@ "SDR Shadows": "Stíny SDR", "SDR Whites": "Bílé SDR", "HDR TIFF needs 32-bit float.": "HDR TIFF vyžaduje 32bitové hodnoty s plovoucí řádovou čárkou.", + "HDR output": "Výstup HDR", + "Letter": "Letter", "Color space: Rec. 2020 HLG (HDR AVIF)": "Barevný prostor: Rec. 2020 HLG (HDR AVIF)", "Color space: Rec. 2020 PQ (HDR AVIF)": "Barevný prostor: Rec. 2020 PQ (HDR AVIF)", "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Barevný prostor: Rec. 2020 PQ, 16 bitů (HDR PNG)", @@ -2063,5 +2239,158 @@ "Open Settings": "Otevřít nastavení", "Select none": "Zrušit celý výběr", "Select all": "Vybrat vše", - "Visualize HDR": "Zobrazit rozsah HDR" + "Visualize HDR": "Zobrazit rozsah HDR", + "Add a Face Recognition Model": "Přidat model rozpoznávání obličejů", + "Choose Monitor Profile": "Vybrat profil monitoru", + "Display profile not used": "Profil displeje nebyl použit", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Tažením nastavíte odstín a sytost. Podržením Shift zachováte odstín, Cmd/Ctrl zachováte sytost, Alt/Option umožní jemné doladění. Dvojitým kliknutím obnovíte výchozí hodnoty.", + "Edit In": "Upravit v", + "Face models (ONNX)": "Modely rozpoznávání obličejů (ONNX)", + "ICC Profiles": "Profily ICC", + "Next History Step": "Další krok historie", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) používá kompresi intoPIX TicoRAW. LightCraft pro tento kodek v současnosti nemá licenci, takže je k dispozici pouze vložený náhled JPEG z fotoaparátu, nikoli data RAW ze snímače. Chcete-li upravovat data RAW, fotografujte do bezeztrátově komprimovaného NEF nebo převeďte stávající soubory HE do DNG pomocí licencovaného převodníku.", + "Open Original in Other Editor (RAW, no copy)": "Otevřít originál v jiném editoru (RAW, bez kopie)", + "Photo Above": "Fotografie nad aktuální", + "Photo Below": "Fotografie pod aktuální", + "Previous History Step": "Předchozí krok historie", + "Select Only Active Photo": "Vybrat pouze aktivní fotografii", + "Show this wheel's effect": "Zobrazit účinek tohoto barevného kruhu", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Cílená úprava je zapnutá: táhněte po fotografii nahoru nebo dolů · Esc ukončí úpravu", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Chcete-li tomuto editoru předat přímo původní soubor (RAW bez kopie, například pro DxO PhotoLab), klikněte pravým tlačítkem na fotografii → Upravit v → Otevřít originál v jiném editoru (RAW, bez kopie).", + "A slide stays up for its duration, its fade in included. A fade of 0 cuts.": "Snímek se zobrazuje po nastavenou dobu včetně úvodního prolnutí. Prolnutí o délce 0 znamená střih.", + "Add Print Cell": "Přidat tiskovou buňku", + "Add a cell": "Přidat buňku", + "Also the masks of the same name on the other selected photos": "Také masky se stejným názvem na ostatních vybraných fotografiích", + "Apply Mask Effect": "Použít efekt masky", + "Apply Print Template": "Použít tiskovou šablonu", + "Apply Slideshow Template": "Použít šablonu prezentace", + "Apply to All Masks": "Použít na všechny masky", + "Arrange Print Cell": "Změnit pořadí tiskové buňky", + "Auto Layout": "Automaticky rozmístit", + "Choose Layout Image…": "Vybrat obrázek rozložení…", + "Choose a group": "Vyberte skupinu", + "Choose at least one setting to include": "Vyberte alespoň jedno nastavení, které chcete zahrnout", + "Clear Print Layout": "Vyprázdnit tiskové rozložení", + "Click a cell to move, resize or arrange it": "Kliknutím vyberte buňku, kterou chcete přesunout, změnit její velikost nebo pořadí", + "Color wheel": "Barevný kruh", + "Combine with the existing person": "Sloučit s existující osobou", + "Copy Settings…": "Kopírovat nastavení…", + "Curve channel": "Kanál křivky", + "Cycle Flag": "Přepnout příznak", + "Decrease Flag Status": "Snížit stav příznaku", + "Delete Cell": "Odstranit buňku", + "Delete Mask Effect": "Odstranit efekt masky", + "Delete Print Cell": "Odstranit tiskovou buňku", + "Delete Print Template": "Odstranit tiskovou šablonu", + "Delete Slideshow Template": "Odstranit šablonu prezentace", + "Delete…": "Odstranit…", + "Duplicate Mask Effect": "Duplikovat efekt masky", + "Export Effect Presets": "Exportovat přednastavení efektů", + "Export Mask Effects": "Exportovat efekty masek", + "Face Models on the GPU": "Modely rozpoznávání obličejů na GPU", + "Fade": "Prolnutí", + "Fade duration": "Délka prolnutí", + "Favorite": "Oblíbené", + "Favorite Mask Effect": "Přidat efekt masky mezi oblíbené", + "Filters": "Filtry", + "Group (optional)": "Skupina (nepovinná)", + "Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.": "Zvýrazněné jsou posuvníky, které tato maska mění. Nezahrnuté posuvníky si při použití přednastavení ponechají své hodnoty.", + "Import Mask Effects": "Importovat efekty masek", + "Import Slideshow Template": "Importovat šablonu prezentace", + "Include Amount": "Zahrnout míru", + "Include brush size, feather and flow": "Zahrnout velikost štětce, prolnutí okrajů a tok", + "Increase Flag Status": "Zvýšit stav příznaku", + "LightCraft Effect Presets": "Přednastavení efektů LightCraft", + "Manage Presets…": "Spravovat přednastavení…", + "Managed by Printer (Adobe RGB)": "Spravováno tiskárnou (Adobe RGB)", + "Managed by Printer (sRGB)": "Spravováno tiskárnou (sRGB)", + "Mask": "Maska", + "Mask Effect Presets": "Přednastavení efektů masek", + "Masks keep their sliders. Export presets first to keep a copy.": "Masky si ponechají hodnoty svých posuvníků. Chcete-li si ponechat kopii přednastavení, nejprve je exportujte.", + "Move Mask Effect to Group": "Přesunout efekt masky do skupiny", + "Move Print Cell": "Přesunout tiskovou buňku", + "Move Print Cell to Page": "Přesunout tiskovou buňku na stránku", + "Move to Group…": "Přesunout do skupiny…", + "Name the preset": "Pojmenujte přednastavení", + "New Print Page": "Přidat tiskovou stránku", + "New group — Return": "Nová skupina — Return", + "New spot (Return adds it here)": "Nová retušovaná oblast (Return ji zde přidá)", + "Next page": "Další stránka", + "No Matte": "Bez ztmavení", + "No change": "Beze změny", + "No effect matches": "Žádný odpovídající efekt", + "No effect presets yet": "Zatím žádná přednastavení efektů", + "One photo fills each page inside the margins.": "Každou stránku uvnitř okrajů vyplní jedna fotografie.", + "Overlay color": "Barva překrytí", + "Page": "Stránka", + "Page Options": "Možnosti stránky", + "Previous page": "Předchozí stránka", + "Print Settings": "Nastavení tisku", + "Print Templates": "Tiskové šablony", + "Print to PDF": "Tisknout do PDF", + "Read Focus Points in the Background": "Načíst zaostřovací body na pozadí", + "Recheck Preview-Only Raws": "Znovu zkontrolovat soubory RAW dostupné jen jako náhled", + "Remove region": "Odebrat oblast", + "Rename Mask Effect": "Přejmenovat efekt masky", + "Rename Mask Effect Group": "Přejmenovat skupinu efektů masek", + "Rename Person…": "Přejmenovat osobu…", + "Rename Slideshow Template": "Přejmenovat šablonu prezentace", + "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).": "Další snímky se vykreslují během zobrazení aktuálního, takže prolnutí nikdy nečeká. Vypnuto: každý snímek se vykreslí, až na něj přijde řada (nižší spotřeba paměti a méně výpočtů).", + "Reset Print Settings": "Obnovit nastavení tisku", + "Reset Sliders": "Obnovit posuvníky", + "Resize Print Cell": "Změnit velikost tiskové buňky", + "Resize region": "Změnit velikost oblasti", + "Save Current Settings as New Preset…": "Uložit aktuální nastavení jako nové přednastavení…", + "Save Mask Effect": "Uložit efekt masky", + "Save Print Template": "Uložit tiskovou šablonu", + "Save Slideshow Template": "Uložit šablonu prezentace", + "Search effects": "Hledat efekty", + "Select a mask to apply or update presets.": "Vyberte masku, abyste mohli použít nebo aktualizovat přednastavení.", + "Set Effect for New Masks": "Nastavit efekt pro nové masky", + "Set Print Cell Photo": "Nastavit fotografii tiskové buňky", + "Set Print Settings": "Upravit nastavení tisku", + "Show / hide": "Zobrazit / skrýt", + "Show mask": "Zobrazit masku", + "Slide duration": "Délka snímku", + "Slideshow Export Status": "Stav exportu prezentace", + "Slideshow Layout Settings": "Nastavení rozložení prezentace", + "Slideshow Overlay Settings": "Nastavení překrytí prezentace", + "Slideshow Playback Settings": "Nastavení přehrávání prezentace", + "Slideshow Slide Preview": "Náhled snímku prezentace", + "Slideshow Title Settings": "Nastavení titulků prezentace", + "Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library.": "Položka Prezentace v nabídce Zobrazení přehraje fotografie v aktuálním zobrazení od vybrané fotografie. Tato nastavení se ukládají s knihovnou.", + "Template name": "Název šablony", + "The layout image can't be shown": "Obrázek rozložení nelze zobrazit", + "This page setup is not valid": "Toto nastavení stránky není platné", + "Thumbnail size": "Velikost miniatur", + "Title bar": "Záhlaví okna", + "Tool Overlay": "Překrytí nástroje", + "Ungrouped": "Bez skupiny", + "Unknown date": "Neznámé datum", + "Unnamed face": "Nepojmenovaný obličej", + "Update Mask Effect": "Aktualizovat efekt masky", + "Update Slideshow Template": "Aktualizovat šablonu prezentace", + "When pins show on the photo (Shift+H cycles)": "Kdy se na fotografii zobrazují body (Shift+H přepíná)", + "Write Waiting XMP Sidecars": "Zapsat čekající přidružené soubory XMP", + "cells": "× buňka", + "cells overlap": "buňky se překrývají", + "current edits": "aktuální úpravy", + "size": "velikost", + "source": "zdroj", + "★ Favorites": "★ Oblíbené", + "Rename Person": "Přejmenovat osobu", + "Increase Grid Thumbnail Size": "Zvětšit miniatury v mřížce", + "Decrease Grid Thumbnail Size": "Zmenšit miniatury v mřížce", + "Cycle Tool Overlay": "Přepnout překrytí nástroje", + "Show Layout Image": "Zobrazit obrázek rozložení", + "Layout Image Opacity": "Krytí obrázku rozložení", + "Layout Image Matte": "Ztmavení kolem obrázku rozložení", + "Slideshow Options…": "Možnosti prezentace…", + "Pause / Resume Slideshow": "Pozastavit / obnovit prezentaci", + "Next Slide": "Další snímek", + "Previous Slide": "Předchozí snímek", + "End Slideshow": "Ukončit prezentaci", + "Save Current Settings as New Effect Preset…": "Uložit aktuální nastavení jako nové přednastavení efektu…", + "Manage Effect Presets…": "Spravovat přednastavení efektů…", + "Export Effect Presets…": "Exportovat přednastavení efektů…" } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 42245407d..8dcf0a9d5 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", "· {in_source} identical copies": "· {in_source} identische Kopien", "{count} more warnings.": "{count} weitere Warnungen.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} Katalogbilder wurden nicht importiert, weil ihre Einträge im Katalog unvollständig sind.", "{photos} catalog records processed; {collections} collections.": "{photos} Katalogeinträge verarbeitet; {collections} Sammlungen.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} Originale fehlen. Ihre Katalogeinträge sind verfügbar; verwende „Fehlende Fotos suchen“, um sie neu zu verknüpfen.", "Applied {name}": "{name} angewendet", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 5ea0e969a..022048a26 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", "· {in_source} identical copies": "· {in_source} copias idénticas", "{count} more warnings.": "{count} advertencias más.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "No se importaron {skipped} imágenes del catálogo porque sus registros en el catálogo están incompletos.", "{photos} catalog records processed; {collections} collections.": "{photos} registros del catálogo procesados; {collections} colecciones.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Faltan {missing} originales. Sus registros del catálogo están disponibles; usa Buscar fotos que faltan para volver a vincularlos.", "Applied {name}": "Se aplicó {name}", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 1ecd4c4b2..a091ec5ea 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", "· {in_source} identical copies": "· {in_source} copies identiques", "{count} more warnings.": "{count} avertissements supplémentaires.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} images du catalogue n’ont pas été importées, car leurs entrées dans le catalogue sont incomplètes.", "{photos} catalog records processed; {collections} collections.": "{photos} entrées du catalogue traitées ; {collections} collections.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originaux sont manquants. Leurs entrées de catalogue sont disponibles ; utilisez Rechercher les photos manquantes pour rétablir les liens.", "Applied {name}": "{name} appliqué", diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json index 8edab510a..26d520175 100644 --- a/crates/ui-egui/locales/it-formats.json +++ b/crates/ui-egui/locales/it-formats.json @@ -245,55 +245,56 @@ "{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}", "{n} added to Apple Photos": "Aggiunte a Foto di Apple: {n}", "{n} photos opened for editing; each is stacked with its original": "{n} foto aperte per la modifica; ciascuna è in pila con il suo originale", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", - "{name} opened in the other editor": "{name} opened in the other editor", - "{s} s of AV1 video": "{s} s of AV1 video", - "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", - "{count} more warnings.": "{count} more warnings.", - "· {in_source} identical copies": "· {in_source} identical copies", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", - "{n} preset{}": "{n} preset{}", - " — kept but not applied: {}": " — kept but not applied: {}", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "Saved template {name}": "Saved template {name}", - "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", - " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "Delete “{name}”?": "Delete “{name}”?", - "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "new, inside {parent}": "new, inside {parent}", - "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Originali mancanti: {missing}. I loro dati nel catalogo sono disponibili; usa Trova foto mancanti per ricollegarli.", + "{name} opened in the other editor": "{name} aperto nell'altro editor", + "{s} s of AV1 video": "{s} s di video AV1", + "Delete {n} effect preset{}? This can't be undone.": "Eliminare i preset effetto ({n})?{:.0} L'operazione non si può annullare.", + "{count} more warnings.": "Altri avvisi: {count}.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} immagini del catalogo non sono state importate perché i loro record nel catalogo sono incompleti.", + "· {in_source} identical copies": "· copie identiche: {in_source}", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Unione {what} aggiunta a {mp}, la dimensione massima di un panorama (la dimensione piena è {full})", + "{n} preset{}": "Preset: {n}{:.0}", + " — kept but not applied: {}": " — conservati ma non applicati: {}", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Unione {what} aggiunta a {mp}: la dimensione piena, {full}, richiede più memoria di quella disponibile su questo computer", + "Saved template {name}": "Modello {name} salvato", + "LightCraft stopped unexpectedly: {e}": "LightCraft si è chiuso in modo imprevisto: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Rinomina i volti in tutta la libreria ({n}){:.0}. I file non vengono modificati.", + " · {n} skipped (could not be rendered)": " · saltate: {n} (non è stato possibile eseguire il rendering)", + "Delete “{name}”?": "Eliminare “{name}”?", + "Keywords of {n} selected photos": "Parole chiave delle foto selezionate: {n}", + "new, inside {parent}": "nuova, dentro {parent}", + "Delete these {n} effect presets?": "Eliminare questi preset effetto ({n})?", "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", - "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", - "Skipped {problem}": "Skipped {problem}", - "Updated {name}": "Updated {name}", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", - "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", - "Slideshow exported: {what}": "Slideshow exported: {what}", - "Exported to {path}": "Exported to {path}", - "…and {more} more": "…and {more} more", - "Create the keyword “{name}”": "Create the keyword “{name}”", - "{n} JPEG files": "{n} JPEG files", - "Exported {} effect preset{}": "Exported {} effect preset{}", - "Imported {name}": "Imported {name}", - "{n} pages": "{n} pages", - " · kept, not applied: {}": " · kept, not applied: {}", - "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "{photos} catalog records processed; {collections} collections.": "Record del catalogo elaborati: {photos}; raccolte: {collections}.", + "Skipped {problem}": "Saltato: {problem}", + "Updated {name}": "{name} aggiornato", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Non è stato possibile scrivere i file XMP di alcune foto ({n}): la loro cartella è scollegata? LightCraft continua a riprovare.", + "Apply to “{mask}” on Selected Photos": "Applica a “{mask}” sulle foto selezionate", + "Update “{name}” with Current Settings": "Aggiorna “{name}” con le impostazioni attuali", + "Slideshow exported: {what}": "Presentazione esportata: {what}", + "Exported to {path}": "Esportato in {path}", + "…and {more} more": "…e altri {more}", + "Create the keyword “{name}”": "Crea la parola chiave “{name}”", + "{n} JPEG files": "File JPEG: {n}", + "Exported {} effect preset{}": "Preset effetto esportati: {}{:.0}", + "Imported {name}": "{name} importato", + "{n} pages": "Pagine: {n}", + " · kept, not applied: {}": " · conservato, non applicato: {}", + "Delete the effect preset “{name}”?": "Eliminare il preset effetto “{name}”?", + "{n} raws can now be developed from their raw data": "Raw che ora si possono sviluppare dai loro dati raw: {n}", "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", - "Template preview: {name}": "Template preview: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", - "Applied {name}": "Applied {name}", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", - "{n} originals opened in the other editor": "{n} originals opened in the other editor", - "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", - "Effect: {name}": "Effect: {name}", - " in {g} group{}": " in {g} group{}", - "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", - "Already in “{}”": "Already in “{}”", - "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", - "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", - "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back." + "Template preview: {name}": "Anteprima del modello: {name}", + "{n} photos in view · {slides} slides · {time}": "Foto nella vista: {n} · diapositive: {slides} · {time}", + "Applied {name}": "{name} applicato", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” è già una persona. Unirle ne crea una sola; Annulla le separa di nuovo.", + "{n} originals opened in the other editor": "Originali aperti nell'altro editor: {n}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft si è chiuso in modo imprevisto: {e}\n\nI dettagli sono nel file di log: {path}", + "Effect: {name}": "Effetto: {name}", + " in {g} group{}": " in gruppi: {g}{:.0}", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Spazio colore: Rec. 2020 {curve} (AVIF HDR)", + "{n} selected photos · {pages} pages": "Foto selezionate: {n} · pagine: {pages}", + "Already in “{}”": "Già in “{}”", + "Move {n} photo{} to “{}”": "Sposta foto in “{1}” ({n}){0:.0}", + "Move {n} photo{} to “{}”?": "Spostare le foto in “{1}” ({n})?{0:.0}", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Foto spostate in “{1}”: {n}{0:.0}. Modifica → Annulla le riporta indietro." } diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json index 382fb80c9..14a31cef8 100644 --- a/crates/ui-egui/locales/it.json +++ b/crates/ui-egui/locales/it.json @@ -8,11 +8,13 @@ "+ Rule": "+ Regola", "0: nothing is rejected.": "0: non viene scartato niente.", "1 day": "1 giorno", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · modifiche attuali · adatta senza ritagliare", "2026-04 or 2026-04-12": "2026-04 oppure 2026-04-12", "= exactly": "= esattamente", "A blank template keeps the original names.": "Un modello vuoto mantiene i nomi originali.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro nasconde le foto di questa vista: cambialo o toglilo (Visualizza → Cancella filtri)", "A one-time download of about": "Un download unico di circa", + "A pen's pressure sets the size and flow of the brush": "La pressione della penna regola dimensione e flusso del pennello", "AI Mask Model Status": "Stato del modello IA per le maschere", "About": "Informazioni", "About LightCraft": "Informazioni su LightCraft", @@ -38,6 +40,9 @@ "Add to Favorites": "Aggiungi ai preferiti", "Add to Local": "Aggiungi a Locale", "Add to My Photos": "Aggiungi a Le mie foto", + "Can't be moved here": "Non si possono spostare qui", + "This row covers more than one disk: drop on a folder inside it": "Questa riga comprende più di un disco: rilascia su una cartella al suo interno", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Le foto locali non sono ancora nella libreria: prima Aggiungi a Le mie foto, poi spostale", "Add to Selected Photos": "Aggiungi alle foto selezionate", "Add to Target Album": "Aggiungi all'album di destinazione", "Add to this mask": "Aggiungi a questa maschera", @@ -153,7 +158,7 @@ "Blue channel": "Canale blu", "Blue label": "Etichetta blu", "Blue/Yellow Fringe": "Frange blu/gialle", - "Blurry photos below the score are flagged as rejects.": "Le foto mosse sotto il punteggio vengono contrassegnate come scartate.", + "Blurry photos below the score are flagged as rejects.": "Le foto sfocate sotto il punteggio vengono contrassegnate come scartate.", "Boundary Warp": "Deformazione dei bordi", "Bright & Airy": "Luminoso e arioso", "Bring All to Front": "Porta tutto in primo piano", @@ -198,6 +203,7 @@ "Choose Edit Settings to Copy": "Scegli le impostazioni di modifica da copiare", "Choose Edit Settings to Copy…": "Scegli le impostazioni di modifica da copiare…", "Choose Folder…": "Scegli cartella…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Scegli dove salvare il PDF dopo la conferma. Stampa il PDF dal tuo visualizzatore.", "Choose a photo in the grid or filmstrip": "Scegli una foto nella griglia o nella striscia", "Choose…": "Scegli…", "Cinematic": "Cinematografico", @@ -248,6 +254,9 @@ "Color Range": "Intervallo colori", "Color label cleared": "Etichetta colore tolta", "Color space": "Spazio colore", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Spazio colore: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Spazio colore: Rec. 2020 PQ (JPEG XL HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Spazio colore: Rec. 2020 PQ, 16 bit (PNG HDR)", "Color space: sRGB (AVIF)": "Spazio colore: sRGB (AVIF)", "Colors": "Colori", "Combine files…": "Combina file…", @@ -264,7 +273,14 @@ "Concrete Cool": "Cemento freddo", "Confirm before deleting": "Chiedi conferma prima di eliminare", "Confirm before moving photos to Recently Deleted": "Chiedi conferma prima di spostare le foto in Eliminate di recente", + "Move Photos": "Sposta foto", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "I loro file e i file XMP collegati vengono spostati sul disco. Modifica → Annulla li riporta indietro.", + "Don't ask again": "Non chiedere più", + "Another library was opened since the photos were dropped: nothing was moved": "Dopo il trascinamento delle foto è stata aperta un'altra libreria: non è stato spostato nulla", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Impostazioni → Chiedi conferma prima di spostare le foto in un'altra cartella sul disco la riattiva", + "Confirm before moving photos to another folder on disk": "Chiedi conferma prima di spostare le foto in un'altra cartella sul disco", "Constrain Crop": "Vincola il ritaglio", + "Contact Sheet PDF": "PDF provino a contatto", "Continue": "Continua", "Continue Without Saving": "Continua senza salvare", "Contrast": "Contrasto", @@ -457,7 +473,7 @@ "Export Now": "Esporta ora", "Export Point Curve Presets": "Esporta preset di curva a punti", "Export Point Curve Presets…": "Esporta preset di curva a punti…", - "Export Presets": "Esporta predefiniti", + "Export Presets": "Esporta preset", "Export Presets…": "Esporta preset…", "Export User Presets…": "Esporta preset utente…", "Export stopped unexpectedly": "L'esportazione si è interrotta in modo imprevisto", @@ -465,7 +481,7 @@ "Export with Previous": "Esporta con le impostazioni precedenti", "Exporting": "Esportazione in corso", "Export…": "Esporta…", - "Exposure": "Tempo di esposizione", + "Exposure": "Esposizione", "Exposure (shutter · aperture · ISO)": "Esposizione (tempo · diaframma · ISO)", "Extended Description": "Descrizione estesa", "External Editor": "Editor esterno", @@ -489,6 +505,7 @@ "File name": "Nome file", "File types:": "Tipi di file:", "Filename": "Nome file", + "Filename captions": "Didascalie con il nome del file", "Fill": "Riempi", "Fill Edges": "Riempi i bordi", "Film": "Pellicola", @@ -548,6 +565,10 @@ "Friday": "Venerdì", "Full": "Completo", "Full Screen Preview": "Anteprima a schermo intero", + "Hold View Until Refreshed": "Mantieni la vista fino all'aggiornamento", + "HDR TIFF needs 32-bit float.": "Il TIFF HDR richiede virgola mobile a 32 bit.", + "HDR output": "Uscita HDR", + "Letter": "Lettera", "Lights Out": "Luci spente", "Full screen mode": "Modalità schermo intero", "Full size": "Dimensione piena", @@ -603,6 +624,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "Panorami HDR", "HDR with Last Settings": "HDR con le ultime impostazioni", + "HDR for Each Bracket Set": "HDR per ogni serie a forcella", "HDR…": "HDR…", "Has Edits": "Con modifiche", "Has GPS": "Con GPS", @@ -646,7 +668,7 @@ "Import Photos…": "Importa foto…", "Import Point Curve Presets": "Importa preset di curva a punti", "Import Point Curve Presets…": "Importa preset di curva a punti…", - "Import Presets": "Importa predefiniti", + "Import Presets": "Importa preset", "Import Presets…": "Importa preset…", "Import Profiles": "Importa profili", "Import Profiles & Presets…": "Importa profili e preset…", @@ -764,6 +786,7 @@ "Local folders": "Cartelle locali", "Local library — no cloud account needed": "Libreria locale: non serve un account cloud", "Locate Missing File…": "Trova file mancante…", + "Locate Missing Files…": "Individua file mancanti…", "Locate Photo": "Trova foto", "Location": "Posizione", "Lock": "Blocca", @@ -900,7 +923,12 @@ "No edits yet.": "Ancora nessuna modifica.", "No exposure information": "Nessuna informazione di esposizione", "No filter presets yet": "Ancora nessun preset di filtro", + "No lens data embedded in this file.": "Questo file non contiene dati dell'obiettivo.", "No lens data embedded in this file (DNG lens opcodes only).": "In questo file non ci sono dati dell'obiettivo (solo opcode obiettivo DNG).", + "Export to": "Esporta in", + "Specific folder": "Cartella specifica", + "Same folder as original photo": "Stessa cartella della foto originale", + "Tidying the thumbnail cache": "Riordino della cache delle miniature", "No masks yet. Choose a mask type above.": "Ancora nessuna maschera. Scegli un tipo di maschera qui sopra.", "No matching commands": "Nessun comando corrispondente", "No matching photos": "Nessuna foto corrispondente", @@ -911,6 +939,11 @@ "No new presets": "Nessun nuovo preset", "No photo selected": "Nessuna foto selezionata", "No photos": "Nessuna foto", + "Your library is empty": "La tua libreria è vuota", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Le foto che importi compaiono qui. Nulla viene copiato o spostato, a meno che tu non lo scelga.", + "or drop photos and folders into this window": "oppure trascina foto e cartelle in questa finestra", + "Import photos to get started": "Importa delle foto per iniziare", + "or drop photos into this window": "oppure trascina foto in questa finestra", "No photos found": "Nessuna foto trovata", "No photos in this folder": "Nessuna foto in questa cartella", "No photos match the active filters": "Nessuna foto corrisponde ai filtri attivi", @@ -986,6 +1019,7 @@ "Paste from Previous": "Incolla dalla precedente", "Paste pages": "Incolla pagine", "Pastel Haze": "Foschia pastello", + "Pen Pressure": "Pressione della penna", "People": "Persone", "Percent": "Percentuale", "Percentage": "Percentuale", @@ -1000,6 +1034,7 @@ "Photo Merge": "Unione di foto", "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Modifica nell'editor esterno (⇧⌘E) genera una copia TIFF a 16 bit, la mette in pila con l'originale e la apre qui (il nome di un'app su macOS, il percorso di un programma altrove).", "Photos": "Foto", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Foto modificate in HDR: JPEG con gain map HDR (si vede bene ovunque, luci più brillanti sugli schermi HDR), AVIF a 10 bit, PNG e JPEG XL a 16 bit Rec. 2020 PQ; il TIFF richiede virgola mobile a 32 bit", "Photos per bracket (0 = auto)": "Foto per bracketing (0 = automatico)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Le foto viste in Locale ma mai aggiunte o modificate escono dal catalogo quando la loro cartella non viene sfogliata da questo tempo (controllo all'apertura della libreria). I file restano sul disco; sfogliando la cartella ricompaiono.", "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Le foto che arrivano in questa cartella (scatto collegato, scanner, app di sincronizzazione) vengono aggiunte appena sono complete.", @@ -1020,9 +1055,9 @@ "Preferences": "Preferenze", "Preferences…": "Preferenze…", "Prepare AI Masks": "Prepara le maschere IA", - "Preset": "Predefinito", + "Preset": "Preset", "Preset name": "Nome del preset", - "Presets": "Predefiniti", + "Presets": "Preset", "Presets & Profiles": "Preset e profili", "Presets (Shift+P)": "Preset (Maiusc+P)", "Presets and profiles": "Preset e profili", @@ -1123,6 +1158,7 @@ "Refining the mask's detail…": "Rifinitura del dettaglio della maschera…", "Refresh Source": "Aggiorna l'origine", "Refresh Source (/)": "Aggiorna l'origine (/)", + "Refresh View": "Aggiorna vista", "Reject": "Scarta", "Reject below focus": "Scarta sotto la soglia di messa a fuoco", "Rejected": "Rifiutato", @@ -1188,7 +1224,30 @@ "Reset Section": "Reimposta sezione", "Reset Tone Curve": "Reimposta curva dei toni", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Reimposta tutte le curve: curve a punti (tutti i canali) e parametrica (doppio clic su un canale per reimpostare solo quello)", + "Reset all edits": "Ripristina tutte le modifiche", "Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Gli album smart si aggiornano da soli: cambia le loro regole, oppure usa Foto → Elimina foto", + "LightCraft stopped": "LightCraft si è chiuso", + "Show Focus Point": "Mostra punto di messa a fuoco", + "Zoom to Focus Point": "Zoom sul punto di messa a fuoco", + "Focus": "Messa a fuoco", + "Focus point": "Punto di messa a fuoco", + "Wide": "Ampio", + "Center": "Centro", + "Flexible Spot": "Spot flessibile", + "Zone": "Zona", + "Expanded Flexible Spot": "Spot flessibile espanso", + "Custom AF Area": "Area AF personalizzata", + "Face tracking": "Tracciamento volti", + "Lock On AF": "AF con aggancio", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Tracciamento", + "Face Tracking": "Tracciamento volti", + "Animal Eye Tracking": "Tracciamento occhi degli animali", + "Human Eye Tracking": "Tracciamento occhi umani", + "Read Focus Points": "Leggi punti di messa a fuoco", + "Reading focus points": "Lettura dei punti di messa a fuoco", "Reshuffle": "Rimescola", "Resize Face Box": "Ridimensiona riquadro del volto", "Resize panel window": "Ridimensiona la finestra del pannello", @@ -1314,7 +1373,7 @@ "Shadows Tint": "Tinta delle ombre", "Shadows split": "Divisione delle ombre", "Sharpen": "Nitidezza", - "Sharpening": "Contrasto", + "Sharpening": "Nitidezza", "Sharpness": "Nitidezza", "Shift": "Maiusc", "Shift by": "Sposta di", @@ -1327,6 +1386,8 @@ "Show Luminance Map": "Mostra mappa di luminanza", "Show Mask": "Mostra maschera", "Show Mask Overlay": "Mostra sovrapposizione maschera", + "Show All Mask Overlays": "Mostra tutte le sovrapposizioni maschera", + "Show all overlays": "Mostra tutte le sovrapposizioni", "Show Mask Pins": "Mostra i punti delle maschere", "Show Original": "Mostra originale", "Show Photo Counts": "Mostra il numero di foto", @@ -1345,6 +1406,7 @@ "Show overlay (O)": "Mostra sovrapposizione (O)", "Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica", "Show the Navigator while zoomed in": "Mostra il Navigatore quando lo zoom è attivo", + "Show the import review when a camera or card is connected": "Mostra la revisione dell'importazione quando si collega una fotocamera o una scheda", "Show the template tags; click one to insert it": "Mostra i tag del modello; fai clic su uno per inserirlo", "Show/Hide Mask": "Mostra/nascondi maschera", "Show/hide My Photos panel": "Mostra/nascondi il pannello Le mie foto", @@ -1372,6 +1434,7 @@ "Soft Pastel": "Pastello morbido", "Soft Proofing": "Prova colore a video", "Soft Skin": "Pelle morbida", + "Solo Mode": "Un pannello alla volta", "Sort": "Ordina", "Sort by": "Ordina per", "Source": "Origine", @@ -1518,6 +1581,7 @@ "Vibrance": "Vividezza", "Videos": "Video", "View": "Visualizza", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "I visualizzatori che non leggono il blocco cICP del PNG mostrano i valori PQ come sRGB: piatti e slavati. Per un'immagine che appaia corretta ovunque, usa JPEG (gain map).", "Viewing and sharing": "Visione e condivisione", "Vignette": "Vignettatura", "Vignetting": "Vignettatura", @@ -1550,6 +1614,7 @@ "Will be restored": "Verrà ripristinata", "Window": "Finestra", "Winter Blue": "Blu d'inverno", + "Wrote the XMP sidecars that were waiting": "Scritti i file XMP che erano in attesa", "XMP Sidecar Preferences": "Preferenze dei file XMP collaterali", "XMP sidecars": "File XMP collaterali", "Year": "Anno", @@ -1716,6 +1781,8 @@ "cached picture": "immagine in cache", "cached pictures": "immagini in cache", "commercial use allowed": "uso commerciale consentito", + "Reading photos…": "Lettura delle foto…", + "Adding photos…": "Aggiunta delle foto…", "denoiseModel.accept": "denoiseModel.accept", "in use": "in uso", "installed": "installato", @@ -1751,6 +1818,95 @@ "Decrease Rating": "Diminuisci valutazione", "Increase Rating": "Aumenta valutazione", "Click to type a value": "Fai clic per scrivere un valore", + "Run face models on the CPU only": "Esegui i modelli dei volti solo sulla CPU", + "Detection": "Rilevamento", + "Recognition": "Riconoscimento", + "Not loaded yet": "Non ancora caricato", + "Checking the GPU…": "Controllo della GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modello per i volti usa la GPU solo quando un controllo mostra che dà gli stessi risultati più velocemente.", + "Checking the GPU for faces": "Controllo della GPU per i volti", + "Include selected photos": "Includi le foto selezionate", + "Choose Profile…": "Scegli profilo…", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Modifica in HDR: luci oltre il bianco SDR, per esportazioni e schermi HDR", + "Edit in Other Editor": "Modifica nell'altro editor", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Modifica nell'altro editor (⌥⌘E) apre la copia in questa applicazione (il nome di un'app su macOS, il percorso di un programma altrove).", + "Edit in PhotoCraft": "Modifica in PhotoCraft", + "External Editing": "Modifica esterna", + "Found automatically": "Trovato automaticamente", + "HDR": "HDR", + "LUT-based": "Basato su LUT", + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "A sinistra: la gamma standard (SDR) fino al bianco SDR. A destra: la gamma HDR, una fascia per ogni stop sopra il bianco SDR (la linea arancione: il margine HDR). Con Visualizza gamma HDR attivo, le fasce ne prendono i colori.", + "Matrix/TRC": "Matrice/TRC", + "Monitor profile": "Profilo monitor", + "None (the display is treated as sRGB)": "Nessuno (lo schermo viene trattato come sRGB)", + "Other editor": "Altro editor", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Foto → Modifica in → Modifica in PhotoCraft (⇧⌘E, o clic destro su una foto) esegue il rendering di una copia con le tue modifiche, la impila con l'originale e la apre in PhotoCraft. Salva lì e la copia si aggiorna qui. Come PSD, PhotoCraft salva direttamente su di essa. Lascia vuota la posizione a meno che tu non usi una copia portatile.", + "PhotoCraft": "PhotoCraft", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Le foto vengono mostrate attraverso il profilo ICC del monitor, così i colori sono corretti su schermi calibrati e a gamut esteso, e la vista Dettaglio usa tutto il gamut dello schermo. Istogramma, cache delle anteprime ed esportazioni non cambiano.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Le foto restano in Tutte le foto. Eliminando una cartella si eliminano anche i suoi album. Puoi annullare l'operazione.", + "Primaries (x, y)": "Primari (x, y)", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "I profili assegnati nelle impostazioni colore del desktop di solito sono in ~/.local/share/icc.", + "Progressive": "Progressivo", + "SDR": "SDR", + "Use Screen Profile": "Usa profilo dello schermo", + "Use sRGB": "Usa sRGB", + "this screen's profile": "il profilo di questo schermo", + "Show photos": "Mostra foto", + "Their photos in the grid": "Le loro foto nella griglia", + "Click to name this face": "Fai clic per dare un nome a questo volto", + "Apple Photos Imports": "Importazioni da Foto di Apple", + "Background Tasks": "Attività in background", + "Cancel Background Task": "Annulla attività in background", + "Contact Sheet PDF…": "PDF provino a contatto…", + "Create Keyword": "Crea parola chiave", + "Display": "Schermo", + "Display Profile": "Profilo dello schermo", + "Edit Keyword": "Modifica parola chiave", + "Export Contact Sheet PDF": "Esporta PDF provino a contatto", + "Find Blemishes": "Trova imperfezioni", + "Keyword Info": "Informazioni parola chiave", + "Move Keyword": "Sposta parola chiave", + "Put New Keywords Inside": "Metti dentro le nuove parole chiave", + "Scan Folder for Changes": "Cerca modifiche nella cartella", + "Set Folder Color Label": "Imposta etichetta colore cartella", + "Remove Folder Color Label": "Rimuovi etichetta colore cartella", + "Show in Compare": "Mostra in Confronto", + "Update to Current Process": "Aggiorna al processo attuale", + "HDR Headroom": "Margine HDR", + "SDR Brightness": "Luminosità SDR", + "SDR Clarity": "Chiarezza SDR", + "SDR Contrast": "Contrasto SDR", + "SDR Highlights": "Luci SDR", + "SDR Shadows": "Ombre SDR", + "SDR Whites": "Bianchi SDR", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Spazio colore: Rec. 2020 HLG (AVIF HDR)", + "or drop a .onnx file on the window": "oppure trascina un file .onnx sulla finestra", + "Already installed. Installing again is harmless.": "Già installato. Installarlo di nuovo non crea problemi.", + "Enter to confirm, Esc to cancel": "Invio per confermare, Esc per annullare", + "Add a model file…": "Aggiungi un file di modello…", + "Open Settings": "Apri impostazioni", + "Select none": "Deseleziona tutto", + "Select all": "Seleziona tutto", + "Visualize HDR": "Visualizza HDR", + "Add a Face Recognition Model": "Aggiungi un modello di riconoscimento dei volti", + "Choose Monitor Profile": "Scegli profilo monitor", + "Display profile not used": "Profilo dello schermo non usato", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "Trascina per impostare tonalità e saturazione. Tieni premuto Maiusc per mantenere la tonalità, Cmd/Ctrl per mantenere la saturazione, Alt/Opzione per una regolazione fine. Doppio clic per ripristinare.", + "Edit In": "Modifica in", + "Face models (ONNX)": "Modelli dei volti (ONNX)", + "ICC Profiles": "Profili ICC", + "M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50, R100 e R8. I file CR3 che il decoder non legge ancora si aprono comunque dal JPEG incorporato, come prima.", + "Next History Step": "Passo successivo della cronologia", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "I NEF Nikon ad alta efficienza (HE / HE★) usano la compressione intoPIX TicoRAW. LightCraft al momento non ha una licenza per questo codec, quindi è disponibile solo l'anteprima JPEG incorporata dalla fotocamera, non i dati raw del sensore. Per modificare i dati raw, scatta in NEF compresso senza perdita oppure converti i file HE esistenti in DNG con un convertitore dotato di licenza.", + "Open Original in Other Editor (RAW, no copy)": "Apri l'originale nell'altro editor (RAW, senza copia)", + "Photo Above": "Foto sopra", + "Photo Below": "Foto sotto", + "Previous History Step": "Passo precedente della cronologia", + "Select Only Active Photo": "Seleziona solo la foto attiva", + "Show this wheel's effect": "Mostra l'effetto di questa ruota", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "Regolazione mirata attiva: trascina in su o in giù sulla foto · Esc per terminare", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "Per passare invece a questo editor il file originale (il raw, senza copia: ad esempio DxO PhotoLab), fai clic destro su una foto → Modifica in → Apri l'originale nell'altro editor (RAW, senza copia).", "Angle": "Angolo", "Video": "Video", "Copyrighted": "Protetta da copyright", @@ -1814,6 +1970,15 @@ "Keyword List": "Elenco parole chiave", "Filter Keywords": "Filtra parole chiave", "No keywords yet": "Ancora nessuna parola chiave", + "Show fewer": "Mostra meno", + "Whole names": "Nomi interi", + "Grouped by parent": "Raggruppate per parola chiave superiore", + "Keywords of the selection": "Parole chiave della selezione", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Raggruppate: le parole chiave dentro la stessa parola chiave condividono una riga col suo nome, e gli elenchi lunghi e i suggerimenti vengono accorciati.", + "Show every keyword of the selected photos": "Mostra tutte le parole chiave delle foto selezionate", + "new keyword": "nuova parola chiave", + "Return creates this keyword and gives it to the selected photos": "Invio crea questa parola chiave e la assegna alle foto selezionate", + "Separate several keywords with commas. Return gives them to the selected photos.": "Separa più parole chiave con le virgole. Invio le assegna alle foto selezionate.", "No keywords match": "Nessuna parola chiave corrisponde", "Keyword Name": "Nome della parola chiave", "Synonyms": "Sinonimi", @@ -1953,6 +2118,111 @@ "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.", "Adding to Apple Photos": "Aggiunta a Foto di Apple", "Add Face Model…": "Aggiungi modello dei volti…", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nessun processore grafico trovato: LightCraft esegue il rendering sulla CPU, molto più lentamente. Dettagli in Aiuto → Info sistema.", + "On NixOS, start it from `nix develop`.": "Su NixOS, avvialo da `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "File con lo stesso contenuto di un altro file di questa importazione: viene importato solo il primo.", + "Lightroom Catalog Import": "Importazione catalogo di Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Queste foto sono solo in memoria. Apri una libreria su disco e importa di nuovo il catalogo per salvarle.", + "Show in Library": "Mostra nella libreria", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "I cataloghi di Lightroom vengono importati nella libreria aperta; non viene creata una cartella di catalogo separata.", + "Destination library": "Libreria di destinazione", + "Playback": "Riproduzione", + "Layout": "Layout", + "Link all margins": "Collega tutti i margini", + "Margins": "Margini", + "Left": "Sinistra", + "Right": "Destra", + "Top": "In alto", + "Bottom": "In basso", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "I margini sono una frazione del lato corto della diapositiva, così le diapositive appaiono uguali su qualsiasi schermo.", + "Show guides": "Mostra guide", + "Aspect preview": "Anteprima proporzioni", + "Zoom to fill frame": "Ingrandisci per riempire la cornice", + "Stroke border": "Bordo a tratto", + "Colour": "Colore", + "Cast shadow": "Ombra portata", + "Offset": "Spostamento", + "Backdrop": "Fondale", + "Background colour": "Colore di sfondo", + "Colour wash": "Velatura di colore", + "Background image": "Immagine di sfondo", + "(not in the library)": "(non nella libreria)", + "Use selected photo": "Usa la foto selezionata", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Seleziona una foto, poi attiva questa opzione per riempire con essa lo sfondo dietro ogni foto.", + "Reset layout": "Ripristina layout", + "Select a photo to preview its slide": "Seleziona una foto per vedere l'anteprima della sua diapositiva", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto selezionata come diapositiva. La riproduzione riempie lo schermo; l'anteprima delle proporzioni cambia solo questa anteprima.", + "Overlays": "Sovrapposizioni", + "Identity plate": "Targa di identità", + "Your name or studio": "Il tuo nome o studio", + "Anchored to": "Ancorato a", + "Slide": "Diapositiva", + "Render behind image": "Disegna dietro l'immagine", + "Rating stars": "Stelle di valutazione", + "Text overlays": "Testi sovrapposti", + "Add text overlay": "Aggiungi testo sovrapposto", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Segnaposto: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Il resto del testo appare così come lo scrivi.", + "Use export watermark": "Usa la filigrana di esportazione", + "The export watermark, drawn on the photo and sized relative to it.": "La filigrana di esportazione, disegnata sulla foto e dimensionata rispetto a essa.", + "Titles": "Titoli", + "Intro screen": "Schermata iniziale", + "Ending screen": "Schermata finale", + "Shows": "Mostra", + "Colour only": "Solo colore", + "Override colour": "Sostituisci colore", + "Own duration (off: the slide duration)": "Durata propria (disattivata: la durata della diapositiva)", + "Duration": "Durata", + "Title text": "Testo del titolo", + "Intro": "Apertura", + "Ending": "Chiusura", + "End": "Fine", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "L'apertura viene riprodotta una volta prima della prima foto; la chiusura dopo l'ultima foto, quando Ripeti è disattivato. La targa di identità è quella impostata in Sovrapposizioni. I titoli si dissolvono come le diapositive e l'ordine casuale non li sposta mai.", + "This screen is off: turn it on under Titles to play it.": "Questa schermata è disattivata: attivala in Titoli per riprodurla.", + "The intro screen, before the first photo.": "La schermata iniziale, prima della prima foto.", + "The ending screen, after the last photo.": "La schermata finale, dopo l'ultima foto.", + "Templates": "Modelli", + "Built-in": "Integrati", + "Your templates": "I tuoi modelli", + "None yet: save the current settings as a template below.": "Ancora nessuno: salva le impostazioni attuali come modello qui sotto.", + "Before the last template": "Prima dell'ultimo modello", + "Previous settings": "Impostazioni precedenti", + "New template name": "Nome del nuovo modello", + "Save as Template": "Salva come modello", + "Import…": "Importa…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "I tuoi modelli richiedono una cartella delle impostazioni, che questa build non ha; quelli integrati funzionano.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Fai clic su un modello per applicarlo; passaci sopra per vederne l'anteprima sulla foto selezionata. Fai clic destro su uno dei tuoi per aggiornarlo, rinominarlo, esportarlo o eliminarlo.", + "Import Slideshow Templates": "Importa modelli di presentazione", + "Export Slideshow Template": "Esporta modello di presentazione", + "Slideshow Templates": "Modelli di presentazione", + "Plain Black": "Nero pieno", + "Gallery Wall": "Parete da galleria", + "Edge to Edge": "Da bordo a bordo", + "Cinema Wide": "Cinema panoramico", + "Shooting Data": "Dati di scatto", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Ogni foto il più grande possibile, su nero, con una breve dissolvenza incrociata. Nulla in sovrimpressione.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Una parete grigio chiaro con passe-partout bianco e un'ombra morbida; didascalia e valutazione sotto la foto.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Ogni foto ritagliata per riempire tutto lo schermo, con dissolvenze incrociate lente.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un'inquadratura 16:9 con bande nere sopra e sotto, il titolo nell'angolo, dissolvenze al nero e un cartello finale.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Uno sfondo ardesia scuro con esposizione, ISO e lunghezza focale, poi fotocamera e obiettivo, sotto ogni foto.", + "Exporting slideshow": "Esportazione della presentazione", + "Export Slideshow…": "Esporta presentazione…", + "Export Slideshow": "Esporta presentazione", + "Export Slideshow: folder for the JPEG files": "Esporta presentazione: cartella per i file JPEG", + "Frame rate": "Fotogrammi al secondo", + "Container": "Contenitore", + "Export PDF…": "Esporta PDF…", + "Export JPEG…": "Esporta JPEG…", + "Export Video…": "Esporta video…", + "Automatically show full screen": "Mostra automaticamente a schermo intero", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Una pagina per diapositiva (il lato lungo in pixel = punti), con la durata e la dissolvenza della diapositiva per la modalità presentazione del visualizzatore.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "File JPEG numerati (nome-001.jpg…) in una nuova cartella, il cui nome si sceglie al passo successivo.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Video AV1, ogni diapositiva per la sua durata con le sue dissolvenze. Senza audio: la musica non viene ancora esportata.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Le diapositive usano layout, sovrapposizioni e schermate iniziale e finale della presentazione, nell'ordine della vista.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Le foto nella vista come diapositive, con le impostazioni qui sopra. L'avanzamento compare nella pila delle attività.", + "Checking raw files": "Controllo dei file raw", + "1 raw can now be developed from its raw data": "1 raw ora può essere sviluppato dai suoi dati raw", + "This raw is now developed from its raw data": "Questo raw ora viene sviluppato dai suoi dati raw", + "This raw variant still can't be decoded": "Questa variante raw non si può ancora decodificare", "Cancel Face Model Download": "Annulla il download del modello dei volti", "Cancel Pending Photo Action": "Annulla l'azione in sospeso sulla foto", "Choose Face Recognition Model": "Scegli il modello di riconoscimento dei volti", @@ -1972,6 +2242,7 @@ "Go back to listing them by name": "Torna all'elenco per nome", "Index Faces": "Indicizza i volti", "Index Faces in the Background": "Indicizza i volti in background", + "Click to type a value, drag to change it": "Fai clic per digitare un valore, trascina per cambiarlo", "Inspect Face Model File": "Esamina il file del modello dei volti", "Install Face Model": "Installa il modello dei volti", "Library Folders": "Cartelle della libreria", @@ -1990,5 +2261,78 @@ "Test Face Model": "Testa il modello dei volti", "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", - "Unnamed Faces": "Volti senza nome" + "Unnamed Faces": "Volti senza nome", + "smart album value|No": "No", + "smart album value|Yes": "Sì", + "Add Print Cell": "Aggiungi cella di stampa", + "Apply Mask Effect": "Applica effetto maschera", + "Apply Print Template": "Applica modello di stampa", + "Apply Slideshow Template": "Applica modello di presentazione", + "Arrange Print Cell": "Disponi cella di stampa", + "Auto Layout": "Layout automatico", + "Choose Layout Image…": "Scegli immagine di layout…", + "Clear Print Layout": "Cancella layout di stampa", + "Cycle Flag": "Alterna bandiera", + "Cycle Tool Overlay": "Alterna sovrapposizione strumento", + "Decrease Flag Status": "Riduci stato bandiera", + "Decrease Grid Thumbnail Size": "Riduci dimensione miniature griglia", + "Delete Mask Effect": "Elimina effetto maschera", + "Delete Print Cell": "Elimina cella di stampa", + "Delete Print Template": "Elimina modello di stampa", + "Delete Slideshow Template": "Elimina modello di presentazione", + "Duplicate Mask Effect": "Duplica effetto maschera", + "End Slideshow": "Termina presentazione", + "Export Effect Presets…": "Esporta preset effetti…", + "Export Mask Effects": "Esporta effetti maschera", + "Face Models on the GPU": "Modelli dei volti sulla GPU", + "Favorite Mask Effect": "Effetto maschera preferito", + "Import Mask Effects": "Importa effetti maschera", + "Import Slideshow Template": "Importa modello di presentazione", + "Increase Flag Status": "Aumenta stato bandiera", + "Increase Grid Thumbnail Size": "Aumenta dimensione miniature griglia", + "Layout Image Matte": "Passe-partout immagine di layout", + "Layout Image Opacity": "Opacità immagine di layout", + "Manage Effect Presets…": "Gestisci preset effetti…", + "Mask Effect Presets": "Preset effetti maschera", + "Move Mask Effect to Group": "Sposta effetto maschera nel gruppo", + "Move Print Cell": "Sposta cella di stampa", + "Move Print Cell to Page": "Sposta cella di stampa nella pagina", + "New Print Page": "Nuova pagina di stampa", + "Next Slide": "Diapositiva successiva", + "Pause / Resume Slideshow": "Pausa / riprendi presentazione", + "Previous Slide": "Diapositiva precedente", + "Print Settings": "Impostazioni di stampa", + "Print Templates": "Modelli di stampa", + "Print to PDF": "Stampa su PDF", + "Read Focus Points in the Background": "Leggi punti di messa a fuoco in background", + "Recheck Preview-Only Raws": "Ricontrolla i raw con sola anteprima", + "Rename Mask Effect": "Rinomina effetto maschera", + "Rename Mask Effect Group": "Rinomina gruppo di effetti maschera", + "Rename Person": "Rinomina persona", + "Rename Slideshow Template": "Rinomina modello di presentazione", + "Reset Print Settings": "Ripristina impostazioni di stampa", + "Resize Print Cell": "Ridimensiona cella di stampa", + "Save Current Settings as New Effect Preset…": "Salva le impostazioni attuali come nuovo preset effetto…", + "Save Mask Effect": "Salva effetto maschera", + "Save Print Template": "Salva modello di stampa", + "Save Slideshow Template": "Salva modello di presentazione", + "Set Effect for New Masks": "Imposta effetto per le nuove maschere", + "Set Print Cell Photo": "Imposta foto della cella di stampa", + "Set Print Settings": "Imposta impostazioni di stampa", + "Show Layout Image": "Mostra immagine di layout", + "Slideshow Export Status": "Stato esportazione presentazione", + "Slideshow Layout Settings": "Impostazioni layout presentazione", + "Slideshow Options…": "Opzioni presentazione…", + "Slideshow Overlay Settings": "Impostazioni sovrapposizioni presentazione", + "Slideshow Playback Settings": "Impostazioni riproduzione presentazione", + "Slideshow Slide Preview": "Anteprima diapositiva della presentazione", + "Slideshow Title Settings": "Impostazioni titoli presentazione", + "Update Mask Effect": "Aggiorna effetto maschera", + "Update Slideshow Template": "Aggiorna modello di presentazione", + "Write Waiting XMP Sidecars": "Scrivi i file XMP in attesa", + "Color Overlay": "Sovrapposizione colore", + "Color Overlay on B&W": "Sovrapposizione colore su B/N", + "Image on Black": "Immagine su nero", + "Image on White": "Immagine su bianco", + "White on Black": "Bianco su nero" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 616c150cf..689a342ec 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -272,6 +272,7 @@ "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", "· {in_source} identical copies": "・同一のコピー {in_source} 件", "{count} more warnings.": "ほかに{count}件の警告があります。", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "カタログ内のレコードが不完全なため、{skipped}件の画像を読み込めませんでした。", "{photos} catalog records processed; {collections} collections.": "カタログの{photos}件のレコードを処理しました。コレクションは{collections}件です。", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。", "Applied {name}": "{name} を適用しました", diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json index 9b08b0d5d..afbfd2815 100644 --- a/crates/ui-egui/locales/ka-formats.json +++ b/crates/ui-egui/locales/ka-formats.json @@ -278,6 +278,7 @@ "Updated {name}": "Updated {name}", "new, inside {parent}": "new, inside {parent}", "{count} more warnings.": "{count} more warnings.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "კატალოგის {skipped} სურათი არ დაიმპორტდა, რადგან მათი ჩანაწერები კატალოგში არასრულია.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", "{n} JPEG files": "{n} JPEG files", "{n} pages": "{n} pages", diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 1886098df..d5352fe61 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -248,50 +248,51 @@ "{n} photos opened for editing; each is stacked with its original": "Zdjęcia otwarte do edycji: {n}; każde połączono w stos z oryginałem", "· {in_source} identical copies": "· identyczne kopie: {in_source}", "· {trashed} in Recently Deleted": "· w Ostatnio usuniętych: {trashed}", - " in {g} group{}": " in {g} group{}", - " · kept, not applied: {}": " · kept, not applied: {}", - " — kept but not applied: {}": " — kept but not applied: {}", - "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", - "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", - "Delete these {n} effect presets?": "Delete these {n} effect presets?", - "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", - "Effect: {name}": "Effect: {name}", - "Exported {} effect preset{}": "Exported {} effect preset{}", - "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", - "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", - "{n} preset{}": "{n} preset{}", - "{count} more warnings.": "{count} more warnings.", - "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", - "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", - "Applied {name}": "Applied {name}", - "Delete “{name}”?": "Delete “{name}”?", - "Updated {name}": "Updated {name}", - "Exported to {path}": "Exported to {path}", - "Saved template {name}": "Saved template {name}", - "Imported {name}": "Imported {name}", - "Skipped {problem}": "Skipped {problem}", - "…and {more} more": "…and {more} more", - "Template preview: {name}": "Template preview: {name}", - "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", - "{n} pages": "{n} pages", - "{n} JPEG files": "{n} JPEG files", - "{s} s of AV1 video": "{s} s of AV1 video", - "Slideshow exported: {what}": "Slideshow exported: {what}", - " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", - "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", - "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", - "Create the keyword “{name}”": "Create the keyword “{name}”", - "new, inside {parent}": "new, inside {parent}", - "Keywords of {n} selected photos": "Keywords of {n} selected photos", - "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", - "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", - "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", - "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", - "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", - "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", - "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + " in {g} group{}": " w grupach: {g}{:.0}", + " · kept, not applied: {}": " · zachowane, niezastosowane: {}", + " — kept but not applied: {}": " — zachowane, ale niezastosowane: {}", + "Apply to “{mask}” on Selected Photos": "Zastosuj do „{mask}” na zaznaczonych zdjęciach", + "Delete the effect preset “{name}”?": "Usunąć ustawienie wstępne efektu „{name}”?", + "Delete these {n} effect presets?": "Usunąć te ustawienia wstępne efektów ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Usunąć ustawienia wstępne efektów ({n})?{:.0} Tej operacji nie można cofnąć.", + "Effect: {name}": "Efekt: {name}", + "Exported {} effect preset{}": "Wyeksportowane ustawienia wstępne efektów: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Zaimportowane efekty masek: {e}{:.0} (Maskowanie → Efekt)", + "Update “{name}” with Current Settings": "Aktualizuj „{name}” bieżącymi ustawieniami", + "{n} preset{}": "Ustawienia wstępne: {n}{:.0}", + "{count} more warnings.": "Pozostałe ostrzeżenia: {count}.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Nie zaimportowano obrazów z katalogu: {skipped}. Ich rekordy w katalogu są niekompletne.", + "{photos} catalog records processed; {collections} collections.": "Przetworzone rekordy katalogu: {photos}; kolekcje: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Brakujące oryginały: {missing}. Ich rekordy w katalogu są dostępne; użyj polecenia „Znajdź brakujące zdjęcia”, aby ponownie je połączyć.", + "Applied {name}": "Zastosowano: {name}", + "Delete “{name}”?": "Usunąć „{name}”?", + "Updated {name}": "Zaktualizowano: {name}", + "Exported to {path}": "Wyeksportowano do {path}", + "Saved template {name}": "Zapisano szablon {name}", + "Imported {name}": "Zaimportowano: {name}", + "Skipped {problem}": "Pominięto: {problem}", + "…and {more} more": "…i jeszcze {more}", + "Template preview: {name}": "Podgląd szablonu: {name}", + "{n} photos in view · {slides} slides · {time}": "Zdjęcia w widoku: {n} · slajdy: {slides} · {time}", + "{n} pages": "Strony: {n}", + "{n} JPEG files": "Pliki JPEG: {n}", + "{s} s of AV1 video": "{s} s wideo AV1", + "Slideshow exported: {what}": "Wyeksportowano pokaz slajdów: {what}", + " · {n} skipped (could not be rendered)": " · pominięte (nie udało się wyrenderować): {n}", + "{n} raws can now be developed from their raw data": "Pliki RAW, które można teraz wywołać z danych surowych: {n}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Nie udało się zapisać plików pomocniczych XMP zdjęć ({n}). Czy ich folder jest offline? LightCraft ponawia próby.", + "Create the keyword “{name}”": "Utwórz słowo kluczowe „{name}”", + "new, inside {parent}": "nowe, wewnątrz {parent}", + "Keywords of {n} selected photos": "Słowa kluczowe zaznaczonych zdjęć ({n})", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Usunięto „{name}” z {n} zdjęć. Edytuj → Cofnij przywraca je.", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft nieoczekiwanie zakończył działanie: {e}\n\nSzczegóły są w pliku dziennika: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft nieoczekiwanie zakończył działanie: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Zmienia nazwę twarzy w całej bibliotece (liczba twarzy: {n}).{:.0} Pliki nie są zmieniane.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}” to już istniejąca osoba. Połączenie utworzy jedną osobę; Cofnij ponownie je rozdzieli.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Dodano połączenie {what} w rozdzielczości {mp}: pełny rozmiar, {full}, wymaga więcej pamięci, niż ma ten komputer", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Dodano połączenie {what} w rozdzielczości {mp}, największej możliwej dla panoramy (pełny rozmiar to {full})", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Przestrzeń kolorów: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "Zaznaczone zdjęcia: {n} · strony: {pages}", "Already in “{}”": "Już w „{}”", "Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}", "Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?", diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json index 7416ce241..f6576b1b5 100644 --- a/crates/ui-egui/locales/pl.json +++ b/crates/ui-egui/locales/pl.json @@ -1511,7 +1511,7 @@ "Versions (Shift+V)": "Wersje (Shift+V)", "Vertical": "Pionowo", "Vertical text": "Tekst pionowy", - "Vibrance": "Dynamika", + "Vibrance": "Jaskrawość", "Videos": "Wideo", "View": "Widok", "Viewing and sharing": "Wyświetlanie i udostępnianie", @@ -2157,5 +2157,115 @@ "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "To jest katalog Lightrooma, a nie biblioteka LightCraft. Zaimportować go zamiast tego do biblioteki LightCraft?", "Unnamed Faces": "Nienazwane twarze", "Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)", - "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)" + "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)", + "Set Folder Color Label": "Ustaw etykietę kolorystyczną folderu", + "Refresh View": "Odśwież widok", + "Hold View Until Refreshed": "Wstrzymaj widok do odświeżenia", + "Photo Below": "Zdjęcie poniżej", + "Photo Above": "Zdjęcie powyżej", + "Cycle Flag": "Przełącz oznaczenie", + "Increase Flag Status": "Podnieś status oznaczenia", + "Decrease Flag Status": "Obniż status oznaczenia", + "Update to Current Process": "Aktualizuj do bieżącej wersji procesu", + "HDR": "HDR", + "Previous History Step": "Poprzedni krok historii", + "Next History Step": "Następny krok historii", + "Find Blemishes": "Znajdź skazy", + "Keyword Info": "Informacje o słowie kluczowym", + "Create Keyword": "Utwórz słowo kluczowe", + "Edit Keyword": "Edytuj słowo kluczowe", + "Move Keyword": "Przenieś słowo kluczowe", + "Put New Keywords Inside": "Umieszczaj nowe słowa kluczowe wewnątrz", + "Mask Effect Presets": "Ustawienia wstępne efektów maski", + "Apply Mask Effect": "Zastosuj efekt maski", + "Save Mask Effect": "Zapisz efekt maski", + "Update Mask Effect": "Aktualizuj efekt maski", + "Rename Mask Effect": "Zmień nazwę efektu maski", + "Duplicate Mask Effect": "Duplikuj efekt maski", + "Delete Mask Effect": "Usuń efekt maski", + "Favorite Mask Effect": "Oznacz efekt maski jako ulubiony", + "Move Mask Effect to Group": "Przenieś efekt maski do grupy", + "Rename Mask Effect Group": "Zmień nazwę grupy efektów maski", + "Set Effect for New Masks": "Ustaw efekt dla nowych masek", + "Import Mask Effects": "Importuj efekty maski", + "Export Mask Effects": "Eksportuj efekty maski", + "HDR for Each Bracket Set": "HDR dla każdej serii ekspozycji", + "Write Waiting XMP Sidecars": "Zapisz oczekujące pliki pomocnicze XMP", + "Slideshow Playback Settings": "Ustawienia odtwarzania pokazu slajdów", + "Slideshow Layout Settings": "Ustawienia układu pokazu slajdów", + "Slideshow Overlay Settings": "Ustawienia nakładek pokazu slajdów", + "Slideshow Title Settings": "Ustawienia tytułów pokazu slajdów", + "Slideshow Templates": "Szablony pokazu slajdów", + "Apply Slideshow Template": "Zastosuj szablon pokazu slajdów", + "Save Slideshow Template": "Zapisz szablon pokazu slajdów", + "Update Slideshow Template": "Aktualizuj szablon pokazu slajdów", + "Rename Slideshow Template": "Zmień nazwę szablonu pokazu slajdów", + "Delete Slideshow Template": "Usuń szablon pokazu slajdów", + "Import Slideshow Template": "Importuj szablon pokazu slajdów", + "Export Slideshow Template": "Eksportuj szablon pokazu slajdów", + "Slideshow Slide Preview": "Podgląd slajdu pokazu", + "Export Slideshow": "Eksportuj pokaz slajdów", + "Slideshow Export Status": "Stan eksportu pokazu slajdów", + "Export Contact Sheet PDF": "Eksportuj arkusz stykowy PDF", + "Apple Photos Imports": "Dodawanie do aplikacji Zdjęcia", + "Print Settings": "Ustawienia drukowania", + "Set Print Settings": "Zmień ustawienia drukowania", + "Reset Print Settings": "Przywróć ustawienia drukowania", + "Add Print Cell": "Dodaj komórkę wydruku", + "Move Print Cell": "Przenieś komórkę wydruku", + "Resize Print Cell": "Zmień rozmiar komórki wydruku", + "Delete Print Cell": "Usuń komórkę wydruku", + "Arrange Print Cell": "Rozmieść komórkę wydruku", + "Set Print Cell Photo": "Ustaw zdjęcie komórki wydruku", + "Move Print Cell to Page": "Przenieś komórkę wydruku na stronę", + "Auto Layout": "Układ automatyczny", + "New Print Page": "Nowa strona wydruku", + "Clear Print Layout": "Wyczyść układ wydruku", + "Print to PDF": "Drukuj do PDF", + "Print Templates": "Szablony wydruku", + "Save Print Template": "Zapisz szablon wydruku", + "Apply Print Template": "Zastosuj szablon wydruku", + "Delete Print Template": "Usuń szablon wydruku", + "Recheck Preview-Only Raws": "Sprawdź ponownie pliki RAW dostępne tylko jako podgląd", + "Scan Folder for Changes": "Skanuj folder w poszukiwaniu zmian", + "Rename Person": "Zmień nazwę osoby", + "Read Focus Points": "Odczytaj punkty ostrości", + "Read Focus Points in the Background": "Odczytuj punkty ostrości w tle", + "Background Tasks": "Zadania w tle", + "Cancel Background Task": "Anuluj zadanie w tle", + "Select Only Active Photo": "Zaznacz tylko aktywne zdjęcie", + "Show Focus Point": "Pokaż punkt ostrości", + "Zoom to Focus Point": "Powiększ do punktu ostrości", + "Show in Compare": "Pokaż w porównaniu", + "Increase Grid Thumbnail Size": "Zwiększ rozmiar miniatur w siatce", + "Decrease Grid Thumbnail Size": "Zmniejsz rozmiar miniatur w siatce", + "Visualize HDR": "Wizualizuj HDR", + "Cycle Tool Overlay": "Przełącz nakładkę narzędzia", + "Choose Layout Image…": "Wybierz obraz układu…", + "Show Layout Image": "Pokaż obraz układu", + "Layout Image Opacity": "Krycie obrazu układu", + "Layout Image Matte": "Matowanie obrazu układu", + "Solo Mode": "Tryb solo", + "Slideshow Options…": "Opcje pokazu slajdów…", + "Export Slideshow…": "Eksportuj pokaz slajdów…", + "Pause / Resume Slideshow": "Wstrzymaj / wznów pokaz slajdów", + "Next Slide": "Następny slajd", + "Previous Slide": "Poprzedni slajd", + "End Slideshow": "Zakończ pokaz slajdów", + "Contact Sheet PDF…": "Arkusz stykowy PDF…", + "Edit in PhotoCraft": "Edytuj w PhotoCraft", + "Edit in Other Editor": "Edytuj w innym edytorze", + "Open Original in Other Editor (RAW, no copy)": "Otwórz oryginał w innym edytorze (RAW, bez kopii)", + "Save Current Settings as New Effect Preset…": "Zapisz bieżące ustawienia jako nowe ustawienie wstępne efektu…", + "Manage Effect Presets…": "Zarządzaj ustawieniami wstępnymi efektów…", + "Export Effect Presets…": "Eksportuj ustawienia wstępne efektów…", + "Display Profile": "Profil ekranu", + "HDR Headroom": "Zapas jasności HDR", + "SDR Brightness": "Jasność SDR", + "SDR Contrast": "Kontrast SDR", + "SDR Highlights": "Światła SDR", + "SDR Shadows": "Cienie SDR", + "SDR Whites": "Biele SDR", + "SDR Clarity": "Przejrzystość SDR", + "Face Models on the GPU": "Modele twarzy przetwarzane na GPU" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 2b17909ad..81b10d3c0 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -272,6 +272,7 @@ "{n} originals opened in the other editor": "{n} originais abertos no outro editor", "· {in_source} identical copies": "· {in_source} cópias idênticas", "{count} more warnings.": "Mais {count} avisos.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "{skipped} imagens do catálogo não foram importadas porque seus registros no catálogo estão incompletos.", "{photos} catalog records processed; {collections} collections.": "{photos} registros do catálogo processados; {collections} coleções.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originais estão ausentes. Seus registros no catálogo estão disponíveis; use Encontrar fotos ausentes para vinculá-los novamente.", "Applied {name}": "{name} aplicado", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 55d5671aa..cefaebd47 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", "· {in_source} identical copies": "· идентичных копий: {in_source}", "{count} more warnings.": "Других предупреждений: {count}.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не импортированы изображения каталога: {skipped}. Их записи в каталоге неполные.", "{photos} catalog records processed; {collections} collections.": "Обработано записей каталога: {photos}; коллекций: {collections}.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не найдены оригиналы: {missing}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки.", "Applied {name}": "Применено: {name}", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 4cf3dc80b..1632e5646 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -268,6 +268,7 @@ "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", "· {in_source} identical copies": "· ідентичних копій: {in_source}", "{count} more warnings.": "Інших попереджень: {count}.", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "Не імпортовано зображення каталогу: {skipped}. Їхні записи в каталозі неповні.", "{photos} catalog records processed; {collections} collections.": "Оброблено записів каталогу: {photos}; колекцій: {collections}.", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не знайдено оригінали: {missing}. Їхні записи каталогу доступні; скористайтеся пошуком відсутніх фотографій, щоб відновити посилання.", "Applied {name}": "Застосовано: {name}", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 0d96d2960..4445f6581 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -204,23 +204,23 @@ "{} label (click more labels to show any of them)": "{0}标签(单击更多标签可显示符合其中任一标签的照片)", ", {} star{}": ",{0} 星{1:.0}", ", {} label": ",{0}标签", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "从 {host} 下载。下载完成并通过校验后,LightCraft 会进行安装,{after}。", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "正在处理器上运行:{}。", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}。保存在图库的“denoise”文件夹中;达到上限时,会先删除没有照片使用的最旧结果。", + "Denoising {name}: {done} of {total} tiles{}": "正在为 {name} 降噪:已完成 {done}/{total} 个图块{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "正在从 {host} 下载:{} / {}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "正在生成降噪结果… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "训练数据:{provenance}", + " · {queued} waiting": " · {queued} 张等待中", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "先显示该模型的条款,然后从 {host} 下载 {},安装并开始使用。除此之外不会上传任何数据。", + "In line for AI Denoise ({ahead} ahead)…": "AI 降噪排队中(前方还有 {ahead} 张)…", + "Running on {}.": "正在{}上运行。", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "放入“{parent}”中", @@ -272,6 +272,7 @@ "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", "· {in_source} identical copies": "· {in_source} 个相同副本", "{count} more warnings.": "另有 {count} 条警告。", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "有 {skipped} 张图像未导入,因为它们在目录中的记录不完整。", "{photos} catalog records processed; {collections} collections.": "已处理 {photos} 条目录记录;{collections} 个收藏集。", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 个原始文件缺失。其目录记录可用;请使用“查找缺失照片”重新链接文件。", "Applied {name}": "已应用 {name}", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2750dd4c8..0bf79c908 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -2179,5 +2179,10 @@ "Checking the GPU…": "正在检查 GPU…", "CPU": "CPU", "A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。", - "Checking the GPU for faces": "正在检查用于人脸的 GPU" + "Checking the GPU for faces": "正在检查用于人脸的 GPU", + "Include selected photos": "包含所选照片", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "根据你已命名的人脸,推测照片中的人物。所有数据仅保存在本机。", + "This build cannot run recognition models: they can be added and chosen, not used.": "此版本无法运行识别模型:可以添加和选择,但无法使用。", + "Detect Faces (Photo menu) needs it: a small one-time download.": "“检测人脸”(照片菜单)依赖它:一次性小文件下载。", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "此版本无法存放人脸模型,因此模型列表不可用。(桌面版可以存放。)" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 62bc3e681..514491c8c 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -272,6 +272,7 @@ "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", "· {in_source} identical copies": "· {in_source} 個相同副本", "{count} more warnings.": "另有 {count} 則警告。", + "{skipped} catalog images were not imported because their records in the catalog are incomplete.": "有 {skipped} 張影像未匯入,因為它們在目錄中的記錄不完整。", "{photos} catalog records processed; {collections} collections.": "已處理 {photos} 筆目錄記錄;{collections} 個收藏集。", "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 個原始檔案遺失。其目錄記錄可用;請使用「尋找遺失相片」重新連結檔案。", "Applied {name}": "已套用 {name}", diff --git a/crates/ui-egui/src/brush_live.rs b/crates/ui-egui/src/brush_live.rs index 83f6e186b..1d31d44d2 100644 --- a/crates/ui-egui/src/brush_live.rs +++ b/crates/ui-egui/src/brush_live.rs @@ -9,7 +9,7 @@ use egui::{Color32, ColorImage, Pos2, Rect, TextureHandle, TextureOptions, pos2}; use lightcraft_geom::Point; -use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs, push_pressures}; +use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs_counted, push_pressures}; /// The largest live-stroke buffer edge (pixels); a bigger canvas is painted at a lower resolution. const MAX_SIDE: usize = 4096; @@ -41,6 +41,8 @@ pub struct LiveStroke { alpha: Vec, /// Samples stamped so far, and the last one (buffer pixels). placed: usize, + /// Total dabs emitted across every stamp, including the first sample. + emitted: usize, last: Option, /// The pen pressure of the last stamped sample. last_pressure: f32, @@ -54,7 +56,7 @@ impl LiveStroke { let scale = buffer_scale(params.clip, params.scale); let params = LiveParams { scale, ..params }; let (w, h) = buffer_size(params.clip, scale); - LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, last_pressure: 1.0, dirty: None, texture: None } + LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, emitted: 0, last: None, last_pressure: 1.0, dirty: None, texture: None } } /// Stroke coverage (0..1) at buffer pixel `(x, y)`; 0 outside. @@ -86,7 +88,7 @@ impl LiveStroke { for (k, q) in new.iter().enumerate() { let q = Point::new(((q.x - clip.left()) * scale) as f64, ((q.y - clip.top()) * scale) as f64); let before = dabs.len(); - if push_dabs(&mut dabs, self.last, q, r) { + if push_dabs_counted(&mut dabs, &mut self.emitted, self.last, q, r) { let pq = pressure.get(first + k).or(pressure.last()).copied().unwrap_or(1.0); let from = if self.last.is_some() { self.last_pressure } else { pq }; push_pressures(&mut pressures, dabs.len() - before, from, pq); @@ -253,6 +255,39 @@ mod tests { assert_eq!(once.alpha, steps.alpha); } + /// Crossing the budget in separate frames must match one stamp, including pressure state. + #[test] + fn incremental_stamping_respects_the_whole_stroke_budget() { + use lightcraft_pipeline::masks::MAX_STROKE_DABS; + + let pts = [pos2(20.0, 20.0), pos2(80.0, 20.0), pos2(200.0, 80.0), pos2(21.0, 20.0)]; + let pressure = [0.2, 0.4, 1.0, 0.9]; + let mut once = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); + once.emitted = MAX_STROKE_DABS - 3; + once.stamp(&pts, &pressure); + let mut steps = LiveStroke::new(once.params); + steps.emitted = MAX_STROKE_DABS - 3; + for n in 1..=pts.len() { + steps.stamp(&pts[..n], &pressure[..n]); + } + assert_eq!(once.alpha, steps.alpha); + assert_eq!(once.emitted, MAX_STROKE_DABS); + assert_eq!(steps.emitted, once.emitted); + assert_eq!(once.last, Some(Point::new(80.0, 20.0))); + assert_eq!(steps.last, once.last); + assert_eq!(once.last_pressure, pressure[1]); + assert_eq!(steps.last_pressure, once.last_pressure); + assert!(once.coverage(80, 20) > 0.0); + assert_eq!(once.coverage(200, 80), 0.0); + let alpha = steps.alpha.clone(); + let more = [pts[0], pts[1], pts[2], pts[3], pos2(300.0, 100.0)]; + steps.stamp(&more, &[1.0; 5]); + assert_eq!(steps.alpha, alpha, "later frames cannot paint or reconnect discarded samples"); + assert_eq!(steps.last, once.last); + assert_eq!(steps.last_pressure, once.last_pressure); + assert_eq!(steps.emitted, MAX_STROKE_DABS); + } + #[test] fn hostile_input_paints_nothing_and_does_not_panic() { let mut s = LiveStroke::new(params()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 097b13c4d..953874f6a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -484,11 +484,8 @@ pub fn default_export_dir() -> String { pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result { use lightcraft_engine::export::{Destination, ExportOptions, export_batch}; let p = &app.session.export_params(p)?; - let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; - if let (Some(path), None) = (p.get("path").and_then(Value::as_str), p.get("format")) { - let ext = path.rsplit_once('.').map_or("", |(_, e)| e); - opts.format = lightcraft_engine::export::ExportFormat::parse(ext).unwrap_or(opts.format); - } + // the format follows `path` (JPEG when it names none), and `bitDepth` is checked against it + let opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?; let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(), None if !app.session.selection.ids.is_empty() => { diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c7dc43392..2a6b95c90 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2252,6 +2252,54 @@ mod tests { assert!(rect[2].as_f64().unwrap() > 50.0 && rect[3].as_f64().unwrap() > 50.0, "{rect}"); } + /// Issue #1952: a previous export saved with a depth its format doesn't have (an API call made + /// before `app.export` refused it) opens at a depth the format offers, so the dialog's Export, + /// which has no depth to pick for JPEG, doesn't fail; a depth the format has is kept. + #[test] + fn export_dialog_drops_a_depth_the_format_lacks() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let opened = |h: &mut Headless, last: Value| { + h.app.ui.dialog = None; + h.app.session.last_export = Some(last); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + match &h.app.ui.dialog { + Some(crate::state::Dialog::Export { opts, .. }) => opts.clone(), + _ => panic!("the export dialog is open"), + } + }; + let o = opened(&mut h, json!({"format": "jpeg", "bitDepth": 16})); + assert_eq!(o.bit_depth, None); + lightcraft_engine::export::ExportOptions::from_params(&o.to_json()).expect("the dialog's settings export"); + assert_eq!(opened(&mut h, json!({"format": "tiff", "bitDepth": 32})).bit_depth, Some(32)); + } + + /// Review of #1966: a `path` whose extension names no format exports JPEG here, so its + /// `bitDepth` is checked against JPEG's, not let through to an 8-bit file. + #[test] + fn export_checks_the_depth_of_the_format_a_bare_path_gets() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let out = std::env::temp_dir().join(format!("lc-bare-path-depth-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + std::fs::create_dir_all(&out).unwrap(); + h.app.services.write = Some(Box::new(|p: &str, bytes: &[u8]| lightcraft_engine::export::write_file(p, bytes))); + for name in ["output", "output.xyz"] { + let p = json!({"path": out.join(name).to_string_lossy(), "longEdge": 32, "bitDepth": 10}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], false, "{name}: {r}"); + assert!(r["error"].as_str().unwrap().contains("`bitDepth` must be 8 for jpeg"), "{name}: {r}"); + } + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 0, "nothing written"); + // the format such a path gets is still JPEG + let p = json!({"path": out.join("output").to_string_lossy(), "longEdge": 32, "bitDepth": 8}); + let r = h.request("engine.execute", json!({"command": "app.export", "params": p}), t); + assert_eq!(r["ok"], true, "{r}"); + let written = std::fs::read_dir(&out).unwrap().next().unwrap().unwrap().path(); + assert!(std::fs::read(&written).unwrap().starts_with(&[0xFF, 0xD8]), "{written:?} is a JPEG"); + let _ = std::fs::remove_dir_all(&out); + } + #[test] fn export_dialog_runs_in_the_background() { let mut h = demo([1200.0, 900.0]); @@ -2707,12 +2755,14 @@ mod tests { h.request("ui.key", json!({"key": "O", "shift": true}), t); assert_eq!(h.app.ui.crop_overlay_orient, 1); assert_eq!(h.app.ui.mask_overlay, mask_overlay, "O doesn't toggle the mask overlay while cropping"); - let locked = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap().crop.aspect.is_some(); - assert!(!locked(&h)); - h.request("ui.key", json!({"key": "A"}), t); + // an untouched photo starts locked to its own shape (#991): A unlocks, A again locks + let locked = |h: &Headless| h.app.crop_tool_aspect().is_some(); assert!(locked(&h)); h.request("ui.key", json!({"key": "A"}), t); assert!(!locked(&h)); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + h.request("ui.key", json!({"key": "A"}), t); + assert!(locked(&h)); assert!(!h.app.ui.visualize_spots); } @@ -2894,15 +2944,56 @@ mod tests { assert!(restore[1] >= 0.0 && restore[1] + restore[3] <= f64::from(size[1]), "the restore row is in view: {restore:?} {after_ids:?}"); assert!(h.app.session.browse.is_some(), "hiding does not stop browsing"); assert!(dir.is_dir(), "the folder itself is untouched"); + // a second hidden place: the row lists them, and each comes back on its own (issue #2114: + // the row used to bring every hidden place back at once) + let dir2 = std::env::temp_dir().join(format!("lc-ui-hide-{}-b", std::process::id())); + let _ = std::fs::remove_dir_all(&dir2); + std::fs::create_dir_all(&dir2).unwrap(); + let path2 = dir2.to_string_lossy().to_string(); + for (command, params) in [("local.addRoot", json!({"path": path2})), ("local.hide", json!({"path": path2}))] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), t); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let menu = |h: &mut Headless| -> Vec { + let w = h.request("ui.widgets", json!({"filter": "local:restoreHidden:"}), t); + w["result"].as_array().unwrap().iter().map(|x| x["id"].as_str().unwrap().to_string()).collect() + }; + assert!(menu(&mut h).is_empty(), "the list opens from the row"); assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); h.settle(SETTLE); - assert!(h.app.ui.hidden_locations.is_empty()); + let items = menu(&mut h); + assert!(items.contains(&format!("local:restoreHidden:{path}")) && items.contains(&"local:restoreHidden:all".to_string()), "{items:?}"); + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("local:restoreHidden:{path}")}), t)["ok"], true); + h.settle(SETTLE); + assert!(!h.app.ui.hidden_locations.iter().any(|x| x == &path), "{:?}", h.app.ui.hidden_locations); + assert!(h.app.ui.hidden_locations.iter().any(|x| x == &path2), "the other place stays hidden"); + assert!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "still something to restore"); + // two hidden again: Restore All brings every one back + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.len() >= 2, "{:?}", h.app.ui.hidden_locations); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "local:restoreHidden:all"}), t)["ok"], true); + h.settle(SETTLE); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), "nothing left to restore"); + // one hidden: the row brings it back itself, no list + let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path2}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.request("ui.clickWidget", json!({"id": "source:local:restoreHidden"}), t)["ok"], true); + h.settle(SETTLE); + assert!(menu(&mut h).is_empty(), "no list for a single hidden place"); + assert!(h.app.ui.hidden_locations.is_empty(), "{:?}", h.app.ui.hidden_locations); if !hidden_before { // (with Home hidden for this test, the folder is back inside Home's tree instead) assert!(ids(&mut h).contains(&format!("source:local:{path}"))); } let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); } /// Local: a kept folder stays a root while its subfolders (and other locations) are @@ -4446,6 +4537,43 @@ mod tests { h.settle(SETTLE); } + /// A hover preview gets the loupe's display fit: it is the size the photo is drawn at, as the + /// loupe render is, not a preview-level render the GPU shrinks. + #[test] + fn a_hover_preview_is_fitted_to_the_drawn_size() { + use crate::render::Slot; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let r = h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step_until(SETTLE, |h| h.app.loupe_shown.map(|l| l.1) == Some("hover")); + let size = |s: Slot| h.app.renderer.textures.get(&s).map(|t| t.size); + let (main, hover) = (size(Slot::Main).expect("loupe render"), size(Slot::Hover).expect("hover render")); + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "the loupe render is fitted: {main:?}"); + assert!(hover[0].abs_diff(main[0]) <= 1 && hover[1].abs_diff(main[1]) <= 1, "hover {hover:?} vs loupe {main:?}"); + } + + /// Zoomed in under a window render, the hidden whole-frame render stays canvas-sized instead of + /// rising to the preview level. + #[test] + fn a_zoomed_view_keeps_its_whole_frame_render_canvas_sized() { + use crate::render::Slot; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "canvas:image", "fx": 0.5, "fy": 0.5}), t); + h.settle(SETTLE); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100.0)); + h.step_until(SETTLE, |h| !h.app.renderer.is_pending(Slot::Main)); + let main = h.app.renderer.textures.get(&Slot::Main).expect("whole-frame render").size; + assert!(main[0].max(main[1]) < lightcraft_engine::SourceLevel::Preview.max_edge(), "{main:?}"); + } + #[test] fn trackpad_navigation_keeps_the_image_cursor_between_events() { let mut h = demo([1000.0, 700.0]); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ed7ad602e..feaba9497 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -149,11 +149,33 @@ impl<'de> Deserialize<'de> for Locale { thread_local! { static LOCALE: std::cell::Cell = const { std::cell::Cell::new(Locale::En) }; + /// The host's answer for the "Auto" language ([`set_default_language`]). + static DEFAULT_LANGUAGE: std::cell::Cell> = const { std::cell::Cell::new(None) }; } -/// The UI language from the environment (`LIGHTCRAFT_LANGUAGE=zh-hans`), for headless runs. +/// Install the language the "Auto" setting resolves to when nothing is stored. The browser host +/// calls this once at startup with the browser's own languages ([`locale_from_tags`]); the +/// desktop host doesn't: there Auto keeps following the environment. Must run before the UI state +/// is first constructed. +pub fn set_default_language(language: Locale) { + DEFAULT_LANGUAGE.with(|slot| slot.set(Some(language))); +} + +/// The first of `tags` (BCP-47, most preferred first, as `navigator.languages` lists them) that +/// names a shipped language; None when none does. +pub fn locale_from_tags(tags: &[String]) -> Option { + tags.iter().find_map(|tag| Locale::parse_tag(tag)) +} + +/// The UI language "Auto" resolves to: the environment's (`LIGHTCRAFT_LANGUAGE=zh-hans`, for +/// headless runs), or the host-installed default ([`set_default_language`], the browser's languages +/// on the web), else English. pub fn default_language() -> Locale { - std::env::var("LIGHTCRAFT_LANGUAGE").ok().and_then(|value| Locale::parse_tag(&value)).unwrap_or(Locale::En) + std::env::var("LIGHTCRAFT_LANGUAGE") + .ok() + .and_then(|value| Locale::parse_tag(&value)) + .or_else(|| DEFAULT_LANGUAGE.with(std::cell::Cell::get)) + .unwrap_or(Locale::En) } pub fn set_language(language: Locale) { @@ -636,6 +658,20 @@ mod tests { assert_eq!(Locale::parse_tag("xx"), None); } + /// The web host's "Auto" (issue #2085): the browser's `navigator.languages` in order, first + /// shipped language wins, unsupported tags are skipped, an empty or foreign list stays English + /// ([`default_language`] fallback). + #[test] + fn locale_from_tags_takes_the_first_shipped_language() { + assert_eq!(locale_from_tags(&[]), None); + assert_eq!(locale_from_tags(&["xx-XX".into()]), None); + assert_eq!(locale_from_tags(&["it-IT".into()]), Some(Locale::It)); + assert_eq!(locale_from_tags(&["xx".into(), "de-DE".into(), "fr".into()]), Some(Locale::De)); + assert_eq!(locale_from_tags(&["zh-TW".into(), "en".into()]), Some(Locale::ZhHant)); + assert_eq!(locale_from_tags(&["ja_JP.UTF-8".into(), "en".into()]), Some(Locale::Ja)); + assert_eq!(locale_from_tags(&["pt".into(), "pt-BR".into()]), Some(Locale::PtBr)); + } + #[test] fn switching_language_translates_and_restores() { set_language(Locale::ZhHans); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 15434237a..0ef3e0448 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -37,6 +37,9 @@ pub enum Icon { StarFilled, Circle, FlagPick, + /// The Pick flag a photo carries: pennant filled, so a pick reads at a glance (#1013). The + /// outlined [`Icon::FlagPick`] stays for buttons and filters. + FlagPickFilled, FlagReject, Gear, Filmstrip, @@ -387,6 +390,14 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.line(&[(5.0, 17.0), (5.0, 3.5)]); pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); } + FlagPickFilled => { + pen.line(&[(5.0, 17.0), (5.0, 3.5)]); + // the notched pennant is concave: two convex halves meet at the notch, overlapping + // along the pole side so their anti-aliased edges leave no seam + pen.fill(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (5.0, 7.5)], color); + pen.fill(&[(5.0, 6.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)], color); + pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); + } FlagReject => { pen.line(&[(5.0, 17.0), (5.0, 3.5)]); pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..a08fb6151 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod pick; pub mod print_view; pub mod recheck; pub mod region; +pub mod reload; pub mod render; pub mod shortcuts; pub mod sidebar; @@ -140,6 +141,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_profile_menu; +#[cfg(test)] mod tests_profiles_layout; #[cfg(test)] mod tests_quit_unsaved; @@ -696,7 +699,10 @@ impl LightcraftApp { if d.crop != lightcraft_develop::Crop::default() { return d.crop.aspect; } - match self.ui.crop_default_aspect.as_ref()? { + // nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one + // Lock toggle (remembered as "free") unlocks it for good + let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into())); + match &remembered { Value::String(s) if s == "original" => { let p = self.session.catalog.photo(photo)?; let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100)); @@ -715,11 +721,13 @@ impl LightcraftApp { /// Called only after a crop gesture begins, inside its undo transaction. pub(crate) fn begin_crop(&mut self) -> Result<(), String> { + // nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it + let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original")); if self.session.interaction.is_some() && self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default()) - && let Some(aspect) = self.ui.crop_default_aspect.clone() && aspect != serde_json::json!("free") - && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect})) + // the remembered ratio, turned like the new photo's crop (as a named ratio is) + && let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"})) { // A remembered value the command refuses (a hand-edited settings file) would refuse // every crop drag on an untouched photo: forget it and let this drag go ahead. diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 241a23d6b..2bda28cf4 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -228,6 +228,8 @@ fn spawn_finalize( unsupported_wasm().map(|_| ()) } +/// What `status` and the result window show for an inspected catalog: counts, missing originals, +/// warnings and skipped images. #[cfg(not(target_arch = "wasm32"))] fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> Value { json!({ @@ -235,6 +237,7 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> "collections": data.collections.iter().filter(|r| r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0) == 0.0).count(), "missing": data.photos.iter().filter(|p| !p.original_is_file()).map(|p| &p.path).collect::>(), "warnings": data.warnings, + "skipped": data.skipped, }) } @@ -298,6 +301,8 @@ fn show_result(app: &mut LightcraftApp) { } } +/// The lasting result window for an inspect or import: counts, failures, missing originals, the +/// skipped images (one line, in the UI language) and at most eight warnings. fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog { let report = value.get("report").unwrap_or(value); let mut details = Vec::new(); @@ -319,6 +324,15 @@ fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog { missing = missing )); } + // images whose catalog records are incomplete come as data (`skipped`), said here in the UI + // language; their ids are in the report for the CLI and agents + let skipped = report["skipped"].as_array().map_or(0, Vec::len); + if skipped > 0 { + details.push(crate::i18n::tr_format!( + "{skipped} catalog images were not imported because their records in the catalog are incomplete.", + skipped = skipped + )); + } if let Some(warning) = value.get("indexWarning").and_then(Value::as_str) { details.push(warning.to_string()); } @@ -669,6 +683,40 @@ mod tests { assert!(h.app.lightroom_result.is_none()); } + #[test] + fn skipped_images_are_one_line_in_the_ui_language() { + let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [ + {"image": 7, "reason": "missingFile"}, + {"image": 8, "reason": "missingRoot"}, + {"image": 9, "reason": "masterSkipped"}, + ]}}); + let details = |locale| { + crate::i18n::set_language(locale); + let dialog = result_dialog(&value, "done"); + crate::i18n::set_language(crate::i18n::Locale::En); + let crate::state::Dialog::LightroomResult { details, .. } = dialog else { panic!("not a Lightroom result") }; + details + }; + let en = details(crate::i18n::Locale::En); + assert_eq!(en.len(), 4, "{en:?}"); + assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete."); + assert_eq!(&en[2..], ["w1", "w2"]); + let es = details(crate::i18n::Locale::Es); + assert_eq!(es[1], "No se importaron 3 imágenes del catálogo porque sus registros en el catálogo están incompletos."); + } + + #[test] + fn skipped_images_do_not_take_the_warning_slots() { + let warnings: Vec<_> = (0..10).map(|i| format!("w{i}")).collect(); + let skipped: Vec<_> = (0..500).map(|i| json!({"image": i, "reason": "missingFile"})).collect(); + let value = json!({"report": {"warnings": warnings, "skipped": skipped}}); + let crate::state::Dialog::LightroomResult { details, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") }; + // one line for the skipped images, then the usual eight warnings and the rest counted + assert_eq!(details.len(), 10, "{details:?}"); + assert!(details[0].starts_with("500 catalog images were not imported")); + assert_eq!(details[9], "2 more warnings."); + } + #[test] fn inspect_report_keeps_empty_catalog_shape() { let report = inspect_report(CatalogImport { @@ -678,6 +726,7 @@ mod tests { members: Vec::new(), collection_content: Vec::new(), warnings: vec!["warning".into()], + skipped: Vec::new(), }); assert_eq!(report["photos"], 0); assert_eq!(report["collections"], 0); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index c5e4e45c7..96463df61 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -962,9 +962,10 @@ pub fn show_in_window(app: &mut LightcraftApp, ui: &mut egui::Ui, max_width: f32 } if let Some((id, params)) = clicked { let r = run_item(app, &id, params); - // an export that can't start (e.g. no folder) says why instead of doing nothing + // an export that can't start (e.g. no folder), or a backup / restore the host refuses + // (changes still unsaved, nothing stored in this browser), says why instead of doing nothing if let Err(e) = r - && matches!(id.as_str(), "app.export" | "app.exportPrevious") + && matches!(id.as_str(), "app.export" | "app.exportPrevious" | "file.backupLibrary" | "file.restoreLibrary") { app.toast(ui.ctx(), e); } @@ -1044,6 +1045,7 @@ fn nodes_ui(ui: &mut egui::Ui, nodes: &[MenuNode], mac: bool, clicked: &mut Opti b = b.shortcut_text(shortcut_text(sc, mac)); } let r = ui.add_enabled(*enabled, b); + crate::widgets::register(ui.ctx(), format!("menuitem:{id}"), r.rect); if *checked == Some(true) { let t = crate::theme::Tokens::get(ui.ctx()); let c = egui::Rect::from_center_size(egui::pos2(r.rect.left() + 11.0, r.rect.center().y), egui::vec2(13.0, 13.0)); @@ -1390,6 +1392,30 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A backup the host refuses (changes still unsaved, issue #1508) says why when started from + /// the menu, as a refused export does; the refusal used to reach only the control channel. + #[test] + fn a_refused_backup_from_the_menu_shows_why() { + // the host refuses, as the web one does while changes are still unsaved (issue #1508) + let refusal = "Backup not started: 1 change could not be saved to browser storage yet"; + let services = crate::Services { + backup_library: Some(Box::new(move |_: &mut lightcraft_engine::Session| Err(refusal.to_string()))), + ..Default::default() + }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); + let t = std::time::Duration::from_secs(30); + h.settle(t); + for id in ["menu:File", "menuitem:file.backupLibrary"] { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + } + // (read at once: a toast keeps the UI repainting until it expires, so settling would outlive it) + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains(refusal), "the refusal is shown: {toast:?}"); + } + /// #1528: `file.findMissing` with `wait: true` must propagate folder validation and search /// failures as errors instead of returning `Ok(null)`. #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 743bd4600..3e8c3653b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -417,6 +417,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Option "pick", Flag::Reject => "reject", Flag::None => "none" }}); diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 6c1b5ba18..75e1e91f7 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -345,7 +345,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo x += 5.0 * 13.0 + 4.0; let fr = Rect::from_min_size(pos2(x, cap.center().y - 7.0), vec2(14.0, 14.0)); match photo.flag { - Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPick, t.pick), + Flag::Pick if fr.right() < cap.right() => paint(pt, fr, Icon::FlagPickFilled, t.pick), Flag::Reject if fr.right() < cap.right() => paint(pt, fr, Icon::FlagReject, t.reject), _ => {} } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 94462e6dc..b3f0ff386 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -145,6 +145,21 @@ pub(crate) fn fit_texture_rect(area: Rect, size: [usize; 2]) -> Rect { Rect::from_center_size(area.center(), vec2(w * scale, h * scale)) } +/// `rect` moved onto whole physical pixels, at exactly the texture's size, when the texture is +/// already the size it is drawn at (within half a pixel), as a display-fitted render at Fit is: centred +/// on a fractional position, the GPU's bilinear filter would average every screen pixel with its +/// neighbour (the 100 % view does the same in [`fit_rect`]). Textures of other sizes are left to +/// the sampler. +pub(crate) fn snap_fit_rect(rect: Rect, size: [usize; 2], ppp: f32) -> Rect { + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let (w, h) = (size[0] as f32, size[1] as f32); + if (rect.width() * ppp - w).abs() > 0.5 || (rect.height() * ppp - h).abs() > 0.5 { + return rect; + } + let snap = |v: f32| (v * ppp).round() / ppp; + Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), vec2(w / ppp, h / ppp)) +} + /// Keep the viewport inside the photo; axes smaller than the viewport stay centred. fn bounded_pan(area: Rect, size: egui::Vec2, pan: (f32, f32)) -> (f32, f32) { let bound = |visible: f32, image: f32, centre: f32| { @@ -531,7 +546,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { && lightcraft_engine::gpu::available(); let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); - let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); + let now = ui.input(|i| i.time); + let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key), now); // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; @@ -558,18 +574,40 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.drag_scale.observe(key, ms, app.perf.host_frame_ms); } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet - let now = ui.input(|i| i.time); let plan = app.size_hold.apply(id, now, navigating, plan); if app.size_hold.holding(now) { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(crate::region::HOLD_SECS)); } - let want = plan.main_edge; - let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; + // At rest the whole frame renders at no less than the preview source level: the source is then + // copied 1:1 (no resample in the pipeline, whose prefilter and bilinear pass soften detail near + // the output's limit) and shrunk once, by the Lanczos display fit below. + // (zoomed in under a window render, the whole frame is hidden: it stays canvas-sized) + let want = if interacting || plan.window_edge.is_some() { + plan.main_edge + } else { + // (a user's explicit Preview size below the preview level still caps it) + let limit = if app.ui.settings.preview_limit == 0 { usize::MAX } else { app.ui.settings.preview_limit as usize }; + let floor = lightcraft_engine::SourceLevel::Preview.max_edge().min(limit).min(native_long); + plan.main_edge.max(floor).min(texture_side.max(crate::state::MIN_TEXTURE_SIDE)) + }; + // The short side is rounded, not truncated: a truncated box (2560 x 1706 for a 2560 x 1707 + // source) made the render a slightly anisotropic resample of its source, a 2-tap blur along + // one axis only. A box that matches the source to rounding is rendered at exactly its size. + let short = |e: usize, a: f32| ((e as f32 / a).round() as usize).max(1); + let (rw, rh) = if aspect >= 1.0 { (want, short(want, aspect)) } else { (short(want, 1.0 / aspect), want) }; // hover and before renders are stand-ins: the preview size is plenty and keeps them cheap let se = app.ui.settings.stand_in_edge(want, texture_side); - let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) }; + let (sw, sh) = if aspect >= 1.0 { (se, short(se, aspect)) } else { (short(se, 1.0 / aspect), se) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; + // at rest the render is shrunk to exactly the size it is drawn at with a Lanczos filter on + // the worker, so the window draws it texel for texel instead of shrinking it with 2-tap + // sampling (the render is up to a 64 px step larger, or the preview level) + let job = if interacting { + job + } else { + job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize) + }; let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof)); let job = job.with_hidden_grading(app.ui.grading_hidden_for(id)); app.renderer.request(Slot::Main, job, 100); @@ -587,6 +625,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // once it is ready (nothing is committed) let hover_key = match app.hover_preview.clone() { Some(h) if !interacting => app.session.preview_job(id, sw.max(8), sh.max(8), !crop_tool, &h.settings).map(|job| { + // (the same display fit as the After view, drawn texel for texel like it) + let job = job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize); let key = job.key; app.renderer.request(Slot::Hover, job, 105); (key, h.label) @@ -625,6 +665,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if (split || show_before || split_view) && let Some(job) = app.session.render_job(id, sw.max(8), sh.max(8), true, !crop_tool) { + // (the same display fit as the After view, so both panes are the same texture size) + let job = if interacting { + job + } else { + job.with_display_fit((target_rect.width() * ppp).round() as usize, (target_rect.height() * ppp).round() as usize) + }; app.renderer.request(Slot::Before, job, 90); } // zoomed past what the whole-frame render holds: also render the part on screen, at no more @@ -692,7 +738,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } else { return "none"; }; - let image_rect = fit_texture_rect(r, tex.size); + let image_rect = snap_fit_rect(fit_texture_rect(r, tex.size), tex.size, ppp); p.with_clip_rect(clip).image(tex.tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); what }; @@ -715,14 +761,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { p.text(label_at(img_rect, main_area), Align2::LEFT_CENTER, crate::i18n::tr("After"), t.font(12.0), t.text_dim); } else if show_before { shown = draw(Slot::Before, img_rect, canvas); - p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, crate::i18n::tr("Before"), t.font(12.0), t.text); + // on the photo itself, so it gets the hover label's dark pill to stay readable over light pictures + let g = p.layout_no_wrap(crate::i18n::tr("Before").to_string(), t.font(12.0), Color32::WHITE); + let bg = label_badge_rect(img_rect, g.size()); + p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); + p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); } else if let Some((_key, label)) = &hover_key && let Some(tex) = app.renderer.textures.get(&Slot::Hover).filter(|t| t.photo == id) { let hover_rect = fit_texture_rect(img_rect, tex.size); p.image(tex.tex.id(), hover_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); let g = p.layout_no_wrap(label.clone(), t.font(12.0), Color32::WHITE); - let bg = Rect::from_min_size(hover_rect.left_top() + vec2(8.0, 8.0), g.size() + vec2(16.0, 8.0)); + let bg = label_badge_rect(hover_rect, g.size()); p.rect_filled(bg, 4.0, Color32::from_black_alpha(160)); p.galley(bg.min + vec2(8.0, 4.0), g, Color32::WHITE); shown = "hover"; @@ -910,6 +960,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// The dark pill behind a label drawn over the photo (`Before`, a hovered preset's name): at the +/// photo's top left, inset 8 points, with the `text` size plus padding. +fn label_badge_rect(photo: Rect, text: egui::Vec2) -> Rect { + Rect::from_min_size(photo.left_top() + vec2(8.0, 8.0), text + vec2(16.0, 8.0)) +} + /// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date /// and size, or with the camera and exposure. fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { @@ -2698,7 +2754,7 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: } x += ph.rating as f32 * 9.0 + 2.0; match ph.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick), + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), Flag::None => {} } @@ -2709,10 +2765,21 @@ fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog: #[cfg(test)] mod preview_geometry_tests { - use super::{fit_rect, fit_texture_rect}; + use super::{fit_rect, fit_texture_rect, label_badge_rect}; use crate::state::Zoom; use egui::{Rect, pos2, vec2}; + /// The "Before" label sits on a dark pill that wraps the text with padding on every side. + #[test] + fn before_label_badge_wraps_the_text() { + let text = vec2(40.0, 14.0); + let photo = Rect::from_min_size(pos2(20.0, 30.0), vec2(400.0, 300.0)); + let bg = label_badge_rect(photo, text); + assert_eq!(bg.min, pos2(28.0, 38.0)); + assert_eq!(bg.size(), vec2(56.0, 22.0)); + assert!(photo.contains_rect(bg)); + } + #[test] fn portrait_preview_keeps_its_ratio_inside_a_landscape_frame() { let frame = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); @@ -3052,6 +3119,27 @@ mod pen_tests { } } +#[cfg(test)] +mod snap_tests { + use super::*; + + /// A texture drawn at its own size lands on whole physical pixels at exactly that size (no + /// bilinear blur); a texture of another size (zoomed, a stand-in) keeps the rect it was given. + #[test] + fn a_fitted_texture_is_drawn_on_whole_pixels_at_its_own_size() { + let r = Rect::from_min_size(pos2(394.3, 130.5), vec2(1088.4, 720.2)); + let s = snap_fit_rect(r, [1088, 720], 1.0); + assert_eq!((s.min.x, s.min.y, s.width(), s.height()), (394.0, 131.0, 1088.0, 720.0)); + // at 1.5 points per pixel the snap is to physical pixels and the size stays in points + let hi = snap_fit_rect(Rect::from_min_size(pos2(100.3, 50.2), vec2(725.4, 480.1)), [1088, 720], 1.5); + assert!(((hi.min.x * 1.5).fract()).abs() < 1e-3 && (hi.width() - 1088.0 / 1.5).abs() < 1e-3, "{hi:?}"); + let other = snap_fit_rect(r, [2560, 1694], 1.0); + assert_eq!(other, r); + let bad = snap_fit_rect(r, [1088, 720], f32::NAN); + assert_eq!((bad.width(), bad.height()), (1088.0, 720.0)); + } +} + #[cfg(test)] mod filter_pill_tests { use super::*; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ded1c0bd0..d53a77a3b 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -169,7 +169,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // content, and the sliders below would run past its edge let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); - egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); + let below = r.rect.bottom(); + egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d, below)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( ui, @@ -429,8 +430,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } /// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and -/// Browse…. -fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { +/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and +/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the +/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. +fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); +} + +fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { use lightcraft_engine::presets::{PROFILES, profile_groups}; let t = Tokens::get(ui.ctx()); ui.set_min_width(200.0); @@ -463,12 +470,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } for g in profile_groups() { let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { - ui.set_min_width(170.0); - for p in PROFILES.iter().filter(|p| p.group == g) { - item(ui, "group", p.id, p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p.id, p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } // imported LUT profile groups let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); @@ -477,12 +489,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) for g in &lut_groups { // a folder's name as it is, and its own widget id even when it matches a built-in group let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { - ui.set_min_width(170.0); - for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { - item(ui, "group", &p.id, &p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { + item(ui, "group", &p.id, &p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } ui.separator(); if let Some((name, _)) = app.session.profile_info(cur) { @@ -1799,13 +1816,16 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { } /// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can -/// decode, whose drive was offline when the library opened), and say what came of it. +/// decode, whose drive was offline when the library opened) in the background; a toast says what +/// came of it once it's done (`crate::reload`). pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { - let r = app.run("photo.reload", json!({"ids": [id.0]})); - let decoded = app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()); - match r { + match app.run("photo.reload", json!({"ids": [id.0]})) { Err(e) => app.toast_error(ctx, e), - Ok(_) if decoded => app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")), + Ok(r) if r["background"] == true => {} + // nothing to read in the background (a photo without a file): answered in place + Ok(_) if app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()) => { + app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")) + } Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), } } diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index b35335cc9..24ec07c1e 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -162,6 +162,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { Flag::Reject if on => t.reject, _ => Color32::TRANSPARENT, }; + let icon = if on && flag == Flag::Pick { Icon::FlagPickFilled } else { icon }; let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color })); if resp.clicked() { filter(app, json!({"flag": if on { Value::Null } else { json!(id) }})); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index c6365b784..8ec5fd6e4 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -709,7 +709,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square } x += photo.rating as f32 * 13.0 + 4.0; match photo.flag { - Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPick, t.pick), + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPickFilled, t.pick), Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject), Flag::None => {} } @@ -1010,7 +1010,7 @@ pub fn label_param(l: Option) -> String { } /// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the -/// members of an expanded stack. Clicking it expands/collapses the stack. +/// members of an expanded stack. Clicking or double-clicking it expands/collapses the stack. pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.catalog.stack(sid) else { return }; @@ -1034,7 +1034,8 @@ pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: } paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE); p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); - if resp.clicked() { + // a double-click (Lightroom's way) toggles once: its second click would undo the first + if resp.clicked() && !resp.double_clicked() { let _ = app.run("stack.toggle", json!({"ids": [id.0]})); } } @@ -1162,7 +1163,10 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { - let _ = app.run("photo.reload", json!({})); + // the files are read in the background (activity row `reload`) + if let Err(e) = app.run("photo.reload", json!({})) { + app.toast_error(ui.ctx(), e); + } } if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() { let _ = app.run("photo.virtualCopy", json!({})); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3bb8deba2..107151b1e 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -793,6 +793,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { struct LocalNow { places: LocalPlaces, current: Option, + /// The places removed from Local, as (name, path), in the order they were hidden. + hidden: Vec<(String, String)>, } /// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, @@ -817,11 +819,14 @@ fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); let current = browsing.as_ref().map(|b| b.path.clone()); + // a hidden built-in place keeps its name (Home, not the user's folder name) + let name_of = |path: &str| builtin.iter().find(|(_, p)| same_folder(p, path)).map_or_else(|| folder_label(path), |(n, _)| n.clone()); + let hidden = app.ui.hidden_locations.iter().map(|h| (name_of(h), h.clone())).collect(); let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); if current.is_some() { app.ui.local_browse_root = places.browse_root.clone(); } - Some(LocalNow { places, current }) + Some(LocalNow { places, current, hidden }) } /// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. @@ -846,18 +851,43 @@ fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { } } } - let hidden = app.ui.hidden_locations.len(); + // the way back for hidden places: one comes back from the row itself; several are listed, so + // each comes back on its own, or all at once (issue #2114: the row used to bring every one back) + let hidden = now.hidden.len(); if hidden > 0 { let label = crate::i18n::tr_format!("Show {hidden} hidden location{}", if hidden == 1 { "" } else { "s" }, hidden = hidden); - if row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) - .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")) - .clicked() - { - let _ = app.run("local.restoreHidden", json!({})); + let resp = row(app, ui, "local:restoreHidden", Icon::Folder, &label, None, false, 0.0) + .on_hover_text(crate::i18n::tr("Put the locations you removed from Local back (no files change)")); + if let [(_, only)] = now.hidden.as_slice() { + if resp.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": only})); + } + } else { + egui::Popup::menu(&resp).show(|ui| hidden_menu(app, ui, &now.hidden)); } } } +/// The list behind "Show N hidden locations": each place by name (its path on hover), then +/// Restore All. +fn hidden_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, hidden: &[(String, String)]) { + for (name, path) in hidden { + let r = ui.button(name).on_hover_text(path); + register(ui.ctx(), format!("local:restoreHidden:{path}"), r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({"path": path})); + ui.close(); + } + } + ui.separator(); + let r = ui.button(crate::i18n::tr("Restore All")); + register(ui.ctx(), "local:restoreHidden:all", r.rect); + if r.clicked() { + let _ = app.run("local.restoreHidden", json!({})); + ui.close(); + } +} + /// Whether two paths name the same folder, however they are spelled (separators, trailing /// slash, `.`/`..`, drive-letter case; see `lightcraft_catalog::query::folder_key`). pub(crate) fn same_folder(a: &str, b: &str) -> bool { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 537330810..6f4c8da3f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -771,7 +771,11 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); if text_button(ui, "fillLocation", crate::i18n::tr("Fill Location"), false) - .on_hover_text(crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)")) + .on_hover_text(format!( + "{}\n{}", + crate::i18n::tr("Fill empty City, State and Country from GPS (offline GeoNames place names)"), + crate::i18n::tr("The place names list towns of 15,000 people or more: in a smaller one, City gets the nearest larger town.") + )) .clicked() && let Err(e) = app.run("metadata.reverseGeocode", json!({})) { diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 953008e0d..6f0a2a690 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -109,16 +109,50 @@ const GIVE_UP_FACTOR: f64 = 2.0; /// The full-size drafts (after the first) a drag times before it may give up on full size. const GIVE_UP_AFTER: usize = 3; -/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. -const SLOW_DRAGS_KEPT: usize = 256; +/// The most (photo, control) pairs [`DragScale`] keeps a verdict on; past it, the one remembered +/// longest ago is forgotten. +const VERDICTS_KEPT: usize = 256; + +/// A host frame time (ms) longer than this is not a texture upload but a window that was hidden, +/// occluded or stalled: it counts as unknown, like a frame time from no host at all. +const FRAME_MS_KNOWN: f64 = 1000.0; + +/// A drag counts against its (photo, control) pair only when its full-size drafts took longer than +/// this (ms): a quarter over the budget, so a drag that only just misses it keeps full size. +const VERDICT_MS: f64 = DRAFT_BUDGET_MS * 1.25; + +/// How long (s) a pair found slow drafts small before a drag of it tries full size again: the +/// machine may only have been busy. Each retry that is slow too doubles the wait, up to +/// [`RETRY_MAX_S`]. A retry costs at most a few slow drafts before the drag gives up on full size. +const RETRY_AFTER_S: f64 = 30.0; + +/// The longest wait (s) before a slow pair tries full size again. +const RETRY_MAX_S: f64 = 240.0; + +/// What [`DragScale`] remembers about the drags of one control on one photo. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Verdict { + /// One drag was over budget at full size: maybe a busy moment, so the next stays full size. + Suspect, + /// Two drags in a row were: drafts are small until `until` (the host's clock, s), then a drag + /// tries full size again; `wait` is how long this verdict holds (s). + Slow { until: f64, wait: f64 }, +} /// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it /// ends (a change in the middle would make the resolution jump), unless its full-size drafts are -/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of -/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] -/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT -/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large -/// draft costs a texture upload too, which only the time a draft took to show tells. +/// far over budget. A drag renders at full size when the GPU draws the view and the same control +/// on this photo isn't known to be too slow at that size; otherwise at [`CPU_DRAFT_SCALE`] (issue +/// #330). Whether the GPU drew a render is not enough on its own: some views (a LUT profile, +/// refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large draft +/// costs a texture upload too, which only the time a draft took to show tells. +/// +/// A drag's time is wall-clock time, what the user waits for. So that a moment the machine was busy +/// (another app, a backup) can't make a slider draft small for the rest of the session, a pair is +/// slow only after two drags in a row over [`VERDICT_MS`], and only for a while: after +/// [`RETRY_AFTER_S`] a drag tries full size again, a fast one forgets the verdict and a slow one +/// doubles the wait. A verdict is for its photo only: the same control on another photo (another +/// size, other edits) is timed afresh. #[derive(Clone, Debug, Default)] pub struct DragScale { /// The drag running now: (photo, control) and its scale. @@ -130,26 +164,28 @@ pub struct DragScale { /// The render time (ms) of a draft that reached the screen this frame: the time of the frame /// that uploads it is only known on the next one. pending: Option, - /// Drags whose full-size drafts went over budget. - slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, + /// Pairs whose full-size drafts went over budget, the one remembered longest ago first. + verdicts: std::collections::VecDeque<((lightcraft_catalog::PhotoId, String), Verdict)>, } impl DragScale { /// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control /// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame - /// render on screen (a render from before the drag is not one of its drafts). - pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option) -> Option { + /// render on screen (a render from before the drag is not one of its drafts); `now` the host's + /// clock (s; egui's `input.time`). + pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option, now: f64) -> Option { let Some((photo, control)) = drag else { - self.finish(); + self.finish(now); return None; }; match &self.current { Some(((p, c), s)) if *p == photo && c == control => return Some(*s), - Some(_) => self.finish(), + Some(_) => self.finish(now), None => {} } let key = (photo, control.to_string()); - let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE }; + let small = matches!(self.verdict(&key), Some(Verdict::Slow { until, .. }) if now < until); + let s = if gpu && !small { 1.0 } else { CPU_DRAFT_SCALE }; self.current = Some((key, s)); self.seen = shown; Some(s) @@ -167,7 +203,7 @@ impl DragScale { return; } if let Some(render) = self.pending.take() { - let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0); + let frame = last_frame_ms.filter(|ms| ms.is_finite() && (0.0..=FRAME_MS_KNOWN).contains(ms)).unwrap_or(0.0); if self.samples.len() < 1024 { self.samples.push(render + frame); } @@ -188,21 +224,58 @@ impl DragScale { } } - /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). + /// Remember drags of `control` on `photo` as too slow for full-size drafts, for good (tests). #[cfg(test)] pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { - self.slow.insert((photo, control.to_string())); + self.set_verdict((photo, control.to_string()), Some(Verdict::Slow { until: f64::INFINITY, wait: RETRY_MAX_S })); } - /// The drag ended: remember it if its full-size drafts were over budget. - fn finish(&mut self) { - if let Some((key, _)) = self.current.take() - && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) - { - if self.slow.len() >= SLOW_DRAGS_KEPT { - self.slow.clear(); + fn verdict(&self, key: &(lightcraft_catalog::PhotoId, String)) -> Option { + self.verdicts.iter().find(|(k, _)| k == key).map(|(_, v)| *v) + } + + /// Set or (`None`) forget a pair's verdict. A new pair forgets the one remembered longest ago + /// when the list is full (not all of them: each forgotten pair costs one more slow drag). + fn set_verdict(&mut self, key: (lightcraft_catalog::PhotoId, String), verdict: Option) { + let at = self.verdicts.iter().position(|(k, _)| *k == key); + match (at, verdict) { + (Some(i), Some(v)) => { + if let Some(entry) = self.verdicts.get_mut(i) { + entry.1 = v; + } } - self.slow.insert(key); + (Some(i), None) => { + self.verdicts.remove(i); + } + (None, Some(v)) => { + while self.verdicts.len() >= VERDICTS_KEPT { + self.verdicts.pop_front(); + } + self.verdicts.push_back((key, v)); + } + (None, None) => {} + } + } + + /// The drag ended. A drag timed at full size updates its pair's verdict: over [`VERDICT_MS`] + /// a first time makes it suspect, a second slow for [`RETRY_AFTER_S`], and a slow retry + /// doubles the wait; within it, the verdict is forgotten. A drag never timed at full size (it + /// drafted small, or was too short) changes nothing. + fn finish(&mut self, now: f64) { + if let Some((key, _)) = self.current.take() + && let Some(median) = median_after_first(&self.samples) + { + let verdict = if median > VERDICT_MS { + let wait = match self.verdict(&key) { + None => None, + Some(Verdict::Suspect) => Some(RETRY_AFTER_S), + Some(Verdict::Slow { wait, .. }) => Some((wait * 2.0).min(RETRY_MAX_S)), + }; + Some(wait.map_or(Verdict::Suspect, |wait| Verdict::Slow { until: now + wait, wait })) + } else { + None + }; + self.set_verdict(key, verdict); } self.samples.clear(); self.seen = None; @@ -440,10 +513,10 @@ mod tests { fn a_drag_shrinks_the_draft_only_on_the_cpu() { use lightcraft_catalog::PhotoId; let mut gpu = DragScale::default(); - assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft"); - assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0)); + assert_eq!(gpu.scale(None, true, None, 0.0), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None, 0.0), Some(1.0)); let mut cpu = DragScale::default(); - assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None, 0.0), Some(CPU_DRAFT_SCALE)); } // Given a drag that started before the GPU was ready, it keeps its size until it ends: the @@ -453,33 +526,120 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size"); + assert_eq!(d.scale(drag, false, None, 0.0), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0), "the next drag gets full size"); // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends - assert_eq!(d.scale(drag, false, None), Some(1.0)); + assert_eq!(d.scale(drag, false, None, 0.0), Some(1.0)); } - // Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders, - // a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts - // small; other controls and photos keep full size + /// One whole drag of `drag` at full size whose drafts all take `ms` to show, ending at `now`. + fn timed_drag(d: &mut DragScale, drag: Option<(lightcraft_catalog::PhotoId, &str)>, ms: f64, now: f64) -> Option { + let scale = d.scale(drag, true, None, now); + for key in 1..=6 { + d.observe(key, ms, None); + } + d.scale(None, true, None, now); + scale + } + + // Given full-size drafts over the slider budget twice in a row (a LUT profile or refined mask + // the CPU renders, a photo past a GPU limit, a slow GPU), the next drag of that control on + // that photo drafts small; once could be a busy moment. Other controls and photos keep full size #[test] - fn a_drag_over_budget_drafts_small_the_next_time() { + fn a_drag_over_budget_twice_drafts_small_the_next_time() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let exposure = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); + assert_eq!(d.scale(exposure, true, Some(7), 0.0), Some(1.0)); d.observe(7, 400.0, None); // the render from before the drag: not a draft of it - for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] { + for (key, ms) in [(8, 300.0), (9, 30.0), (10, 25.0), (11, 9.0)] { d.observe(key, ms, None); d.observe(key, ms, None); // a render is timed once, however many frames show it } - assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag"); - assert_eq!(d.scale(None, true, None), None); - assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); - assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); - assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0)); + assert_eq!(d.scale(exposure, true, None, 0.0), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None, 0.0), None); + assert_eq!(timed_drag(&mut d, exposure, 25.0, 1.0), Some(1.0), "one slow drag: maybe the machine was busy"); + assert_eq!(d.scale(exposure, true, None, 2.0), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 2.0); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None, 2.0), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 2.0), Some(1.0)); + } + + // Given a pair found slow, it drafts small only for a while: then a drag tries full size + // again; a slow retry doubles the wait (up to its cap), a fast one forgets the verdict + #[test] + fn a_slow_verdict_expires_and_is_retried() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 0.0); + timed_drag(&mut d, drag, 25.0, 0.0); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S - 1.0), Some(CPU_DRAFT_SCALE), "slow until the wait is over"); + assert_eq!(timed_drag(&mut d, drag, 25.0, RETRY_AFTER_S), Some(1.0), "then a drag tries full size: still slow"); + let mut at = RETRY_AFTER_S; + let mut wait = RETRY_AFTER_S * 2.0; + for _ in 0..6 { + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait - 1.0), Some(CPU_DRAFT_SCALE), "the wait doubled to {wait} s"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait), Some(1.0), "a retry after {wait} s, slow again"); + at += wait; + wait = (wait * 2.0).min(RETRY_MAX_S); + } + assert_eq!(wait, RETRY_MAX_S, "the wait stops growing"); + assert_eq!(timed_drag(&mut d, drag, 8.0, at + wait), Some(1.0), "a retry, fast this time"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 1.0), Some(1.0), "forgotten: one slow drag is a suspicion again"); + assert_eq!(timed_drag(&mut d, drag, 25.0, at + wait + 2.0), Some(1.0), "suspect: still full size"); + assert_eq!(d.scale(drag, true, None, at + wait + 3.0), Some(CPU_DRAFT_SCALE), "slow from the start again: wait 30 s"); + } + + // Given a drag only just over budget, it doesn't count against the pair (it would flip between + // the two sizes); one clearly over does + #[test] + fn only_a_drag_clearly_over_budget_counts() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..4 { + timed_drag(&mut d, drag, 18.0, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 4.0), Some(1.0), "18 ms drafts: just over the 16 ms budget"); + d.scale(None, true, None, 4.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 5.0); + timed_drag(&mut d, drag, VERDICT_MS + 5.0, 6.0); + assert_eq!(d.scale(drag, true, None, 7.0), Some(CPU_DRAFT_SCALE)); + } + + // Given one slow drag while the machine was busy, then fast ones, the slider keeps full size + #[test] + fn a_busy_moment_doesnt_make_a_slider_draft_small() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 40.0, 0.0); + for t in 1..=3 { + assert_eq!(timed_drag(&mut d, drag, 8.0, f64::from(t)), Some(1.0)); + } + timed_drag(&mut d, drag, 40.0, 4.0); + assert_eq!(d.scale(drag, true, None, 5.0), Some(1.0), "the fast drags forgot the first slow one"); + } + + // Given a hostile host clock (NaN, infinite, going backwards), a drag never panics; a clock it + // can't read tries full size + #[test] + fn a_hostile_clock_never_panics() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + timed_drag(&mut d, drag, 25.0, 100.0); + timed_drag(&mut d, drag, 25.0, 100.0); + assert_eq!(d.scale(drag, true, None, 50.0), Some(CPU_DRAFT_SCALE), "the clock went backwards: still slow"); + d.scale(None, true, None, 50.0); + assert_eq!(d.scale(drag, true, None, f64::NAN), Some(1.0)); + d.scale(None, true, None, f64::NAN); + for now in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -1.0] { + timed_drag(&mut d, drag, 25.0, now); + } } // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be @@ -489,18 +649,18 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); + d.scale(drag, true, None, 0.0); for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); // hostile times are ignored, never a panic for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { d.observe(key, ms, None); } - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + d.scale(None, true, None, 0.0); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); } // Given renders within budget whose frames (the texture upload) take it over, the drag counts @@ -510,50 +670,89 @@ mod tests { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - d.scale(drag, true, None); - for key in 1..=5 { - d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft + for _ in 0..2 { + d.scale(drag, true, None, 0.0); + for key in 1..=5 { + d.observe(key, 8.0, Some(14.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(14.0)); + d.scale(None, true, None, 0.0); } - d.observe(5, 8.0, Some(12.0)); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms"); + assert_eq!(d.scale(drag, true, None, 0.0), Some(CPU_DRAFT_SCALE), "8 ms renders + 14 ms frames: over budget"); // the same renders without the host's frame times (headless) stay within budget let mut headless = DragScale::default(); - headless.scale(drag, true, None); - for key in 1..=6 { - headless.observe(key, 8.0, None); + headless.scale(drag, true, None, 0.0); + for _ in 0..2 { + timed_drag(&mut headless, drag, 8.0, 0.0); } - headless.scale(None, true, None); - assert_eq!(headless.scale(drag, true, None), Some(1.0)); + assert_eq!(headless.scale(drag, true, None, 0.0), Some(1.0)); // a hostile frame time counts as nothing, never a panic let mut hostile = DragScale::default(); - hostile.scale(drag, true, None); + hostile.scale(drag, true, None, 0.0); for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { hostile.observe(key, 8.0, Some(frame)); } - hostile.scale(None, true, None); - assert_eq!(hostile.scale(drag, true, None), Some(1.0)); + hostile.scale(None, true, None, 0.0); + assert_eq!(hostile.scale(drag, true, None, 0.0), Some(1.0)); + } + + // Given more slow drags than are remembered, only the one remembered longest ago is forgotten: + // clearing them all would bring back a slow full-size drag for every pair + #[test] + fn a_full_slow_set_forgets_only_the_oldest_drag() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + for photo in 0..=VERDICTS_KEPT as u64 { + d.remember_slow(PhotoId(photo), "Exposure"); + } + assert_eq!(d.scale(Some((PhotoId(0), "Exposure")), true, None, 0.0), Some(1.0), "the oldest is forgotten"); + d.scale(None, true, None, 0.0); + for photo in [1, VERDICTS_KEPT as u64] { + assert_eq!(d.scale(Some((PhotoId(photo), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE), "photo {photo}"); + d.scale(None, true, None, 0.0); + } + d.remember_slow(PhotoId(1), "Exposure"); // already remembered: nothing is forgotten + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None, 0.0), Some(CPU_DRAFT_SCALE)); + } + + // Given draft frames that took seconds (the window hidden or occluded mid-drag), they count as + // unknown: fast renders don't mark the drag slow because the window wasn't drawing. Two such + // drags, since one slow drag only makes the pair suspect + #[test] + fn a_hidden_windows_frame_times_dont_mark_a_drag_slow() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + for t in 0..2u32 { + assert_eq!(d.scale(drag, true, None, f64::from(t)), Some(1.0), "drag {}", t + 1); + for key in 1..=6 { + d.observe(u64::from(t) * 10 + key, 6.0, Some(4000.0)); + } + d.scale(None, true, None, f64::from(t)); + } + assert_eq!(d.scale(drag, true, None, 2.0), Some(1.0), "after two hidden-window drags: still full size"); } // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), - // the drag drops to the small draft after a few of them instead of at its end, and the next - // drag starts small + // the drag drops to the small draft after a few of them instead of at its end; after a second + // such drag, the next starts small #[test] fn a_drag_far_over_budget_drafts_small_before_it_ends() { use lightcraft_catalog::PhotoId; let mut d = DragScale::default(); let drag = Some((PhotoId(1), "Exposure")); - assert_eq!(d.scale(drag, true, None), Some(1.0)); + assert_eq!(d.scale(drag, true, None, 0.0), Some(1.0)); let mut scales = Vec::new(); for key in 1..=8 { d.observe(key, 190.0, None); - scales.push(d.scale(drag, true, None)); + scales.push(d.scale(drag, true, None, 0.0)); } // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); - d.scale(None, true, None); - assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE)); + d.scale(None, true, None, 0.0); + assert_eq!(timed_drag(&mut d, drag, 190.0, 1.0), Some(1.0), "one such drag: maybe a busy moment"); + assert_eq!(d.scale(drag, true, None, 2.0), Some(CPU_DRAFT_SCALE)); } // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is diff --git a/crates/ui-egui/src/reload.rs b/crates/ui-egui/src/reload.rs new file mode 100644 index 000000000..8748bfd65 --- /dev/null +++ b/crates/ui-egui/src/reload.rs @@ -0,0 +1,80 @@ +//! Reload from Disk in the app: the files are read on a worker thread (a raw's decode takes a +//! second or more, and a selection of them froze the window), with a row in the activity stack +//! that counts the files and can stop. What came of it is applied back here through the session's +//! command wrapper (one undo step, saved, sidecars auto-written), unless another library was opened +//! meanwhile or a reload started later has already been applied to the photo. Agents get the same with `wait: true`, which runs +//! the engine's command in place. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use lightcraft_engine::EngineError; +use lightcraft_engine::cmd::reload; +use serde_json::{Value, json}; + +use crate::LightcraftApp; + +/// The activity stack's label while the files are read (the menu item's name, already translated). +pub const LABEL: &str = "Reload from Disk"; + +/// `photo.reload` from the app: start reading the targeted files in the background → +/// `{background: true, count}`. `None` hands the command to the engine: with `wait: true`, or when +/// no targeted photo has a file (it then answers at once, refusing an empty selection as usual). +pub fn command(app: &mut LightcraftApp, p: &Value) -> Option> { + if p.get("wait").and_then(Value::as_bool) == Some(true) { + return None; + } + let items = match reload::plan(&mut app.session, p) { + Ok(items) => items, + // named as `Session::execute` names a shared parameter check's refusal + Err(EngineError::BadParams { cmd, msg }) if cmd.is_empty() => { + return Some(Err(EngineError::BadParams { cmd: "photo.reload".into(), msg }.to_string())); + } + Err(e) => return Some(Err(e.to_string())), + }; + if items.is_empty() { + return None; + } + let count = items.len(); + // the photos shown from their embedded preview, to say afterwards whether they decode now + let preview_only: Vec<_> = + items.iter().filter(|i| app.session.catalog.photo(i.id).is_some_and(|ph| ph.preview_only.is_some())).map(|i| i.id).collect(); + let library = app.session.library_generation(); + let probe = reload::file_probe(&app.session); + let cancel = Arc::new(AtomicBool::new(false)); + let flag = cancel.clone(); + let stopped = cancel.clone(); + let work = move |task: lightcraft_engine::activity::TaskHandle| { + let total = items.len() as u64; + task.progress(0, total); + let results = reload::probe(probe.as_ref(), &items, &flag, &|done| task.progress(done as u64, total)); + (items, results) + }; + let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results)| { + if app.session.library_generation() != library { + return; + } + // as the command: one undo step, the durable save and the automatic XMP sidecar write + if let Err(e) = app.session.execute_fn("photo.reload", |s| reload::apply(s, items, results).map(|ids| reload::to_json(&ids))) { + return app.toast_error(ctx, e.to_string()); + } + let cleared = preview_only.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|ph| ph.preview_only.is_none())).count(); + // stopped before the files were read: they weren't tried again, so say nothing about them + // (a raw that did decode before the stop is still reported) + if stopped.load(std::sync::atomic::Ordering::Relaxed) && cleared == 0 { + return; + } + let message = match (count, preview_only.len(), cleared) { + (1, 1, 1) => crate::i18n::tr("This raw is now developed from its raw data").to_string(), + (1, 1, _) => crate::i18n::tr("This raw variant still can't be decoded").to_string(), + (_, _, 0) => return, + (_, _, 1) => crate::i18n::tr("1 raw can now be developed from its raw data").to_string(), + (_, _, n) => crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n), + }; + app.toast(ctx, message); + }; + if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, "reload", cancel, work, done) { + return Some(Err(e)); + } + Some(Ok(json!({"background": true, "count": count}))) +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f8c8cc0d4..7268d8a2b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -699,7 +699,8 @@ pub struct UiState { /// Mirroring of the triangle / spiral crop guides (0..4). pub crop_overlay_orient: u8, pub crop_overlay: CropOverlay, - /// Last explicitly chosen aspect, retained across photos and app launches. + /// Last explicitly chosen aspect, retained across photos and app launches; `None` (nothing + /// chosen yet) crops locked to the photo's own shape, as Lightroom starts (#991). pub crop_default_aspect: Option, pub show_filenames: bool, /// What the square grid's caption shows: `filename`, `exposure` (shutter · aperture · ISO · diff --git a/crates/ui-egui/src/tasks.rs b/crates/ui-egui/src/tasks.rs index 70abd629a..86dc79381 100644 --- a/crates/ui-egui/src/tasks.rs +++ b/crates/ui-egui/src/tasks.rs @@ -50,6 +50,34 @@ pub fn spawn( kind: Option<&'static str>, work: impl FnOnce() -> T + Send + 'static, done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, +) -> Result<(), String> { + let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); + start(app, label, guard, work, done) +} + +/// [`spawn`] for a task with progress that can stop: its row (`kind`, `label`) adopts `cancel`, +/// the flag `work` checks, so the row's ✕ and `activity.cancel` stop it; `work` reports progress +/// through the handle it is given. +pub fn spawn_tracked( + app: &mut LightcraftApp, + label: &str, + kind: &'static str, + cancel: std::sync::Arc, + work: impl FnOnce(lightcraft_engine::activity::TaskHandle) -> T + Send + 'static, + done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, +) -> Result<(), String> { + let guard = app.session.activity.start(kind, label, lightcraft_engine::activity::Cancel::Flag(cancel)); + let handle = guard.handle(); + start(app, label, Some(guard), move || work(handle), done) +} + +/// Run `work` on a worker thread and list it in `app.tasks` with its activity row (if any). +fn start( + app: &mut LightcraftApp, + label: &str, + guard: Option, + work: impl FnOnce() -> T + Send + 'static, + done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, ) -> Result<(), String> { let (tx, rx) = channel::(); let repaint = app.tasks.repaint.clone(); @@ -64,7 +92,6 @@ pub fn spawn( std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?; #[cfg(target_arch = "wasm32")] job(); - let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard }); Ok(()) } diff --git a/crates/ui-egui/src/tests_folder_drop.rs b/crates/ui-egui/src/tests_folder_drop.rs index 9b4cb860a..1b6536242 100644 --- a/crates/ui-egui/src/tests_folder_drop.rs +++ b/crates/ui-egui/src/tests_folder_drop.rs @@ -50,8 +50,13 @@ fn widget(h: &Headless, id: &str) -> Option { h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) } +/// How the Folders section names `dir` in widget ids: with `/` separators, on Windows too. +fn folder_key(dir: &Path) -> String { + dir.to_string_lossy().replace('\\', "/") +} + fn folder_row(h: &Headless, dir: &Path) -> Option { - widget(h, &format!("source:libfolder:{}", dir.to_string_lossy())) + widget(h, &format!("source:libfolder:{}", folder_key(dir))) } fn id_of(h: &Headless, file: &str) -> u64 { @@ -180,7 +185,7 @@ fn photos_already_in_the_folder_stay_put() { let (root, trip, home) = folders("here"); let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); // open the folder holding both - let toggle = widget(&h, &format!("libraryFolderToggle:{}", root.to_string_lossy())).unwrap(); + let toggle = widget(&h, &format!("libraryFolderToggle:{}", folder_key(&root))).unwrap(); h.app.synthetic.push(egui::Event::PointerMoved(toggle.center())); h.app.synthetic.push(egui::Event::PointerButton { pos: toggle.center(), @@ -284,7 +289,7 @@ fn cancelling_the_question_moves_nothing() { let target = if folder_row(&h, &home).is_some() { target } else { - click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + click(&mut h, &format!("libraryFolderToggle:{}", folder_key(&root))); folder_row(&h, &home).unwrap() }; let undo = h.app.session.undo.len(); @@ -302,7 +307,7 @@ fn dont_ask_again_moves_the_next_drop_at_once() { let (root, trip, home) = folders("dont-ask"); std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); - click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + click(&mut h, &format!("libraryFolderToggle:{}", folder_key(&root))); let target = folder_row(&h, &home).unwrap(); let beach = id_of(&h, "beach.png"); h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(beach)); @@ -327,7 +332,7 @@ fn dont_ask_again_moves_the_next_drop_at_once() { fn a_question_left_open_while_another_library_opens_moves_nothing() { let (root, trip, home) = folders("other-library"); let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); - click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + click(&mut h, &format!("libraryFolderToggle:{}", folder_key(&root))); let beach = id_of(&h, "beach.png"); let target = folder_row(&h, &home).unwrap(); drop_beach(&mut h, beach, target); diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 7aafb212e..6c85ecf59 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -172,3 +172,48 @@ fn an_unedited_raws_thumbnail_skips_the_sensor_decode() { assert!(!skips_the_real_render(false, true)); assert!(!skips_the_real_render(false, false)); } + +/// Press and release the primary button at `at`, one frame apart from the next click (as a real +/// double-click comes in: one click per frame). +fn click_at(h: &mut Headless, at: egui::Pos2) { + h.events.push(egui::Event::PointerMoved(at)); + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.step(); +} + +/// Double-clicking a stack badge expands or collapses the stack, as in Lightroom (issue #2233): its +/// second click used to undo the first, and the stack stayed as it was. A single click still toggles it. +#[test] +fn double_clicking_a_stack_badge_toggles_the_stack_once() { + let mut h = grid(ViewMode::PhotoGrid); + let top = PhotoId(2001); + let sid = StackId(21); + let collapsed = |h: &Headless| h.app.session.catalog.stack(sid).unwrap().collapsed; + select(&mut h, top); + let r = h.request("engine.execute", json!({"command": "stack.toggle", "params": {"ids": [top.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(collapsed(&h)); + for want in [false, true] { + let badge = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn").center(); + click_at(&mut h, badge); + // the first click re-lays the grid; the second must still land on this badge, or the test + // would pass without the fix (a missed second click toggles nothing) + let moved = widget(&h, &format!("stack:{}", top.0)).expect("stack badge drawn"); + assert!(moved.contains(badge), "the badge moved from under the pointer: {moved:?}"); + click_at(&mut h, badge); + h.settle(SETTLE); + assert_eq!(collapsed(&h), want, "a double-click toggles the stack once"); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "a double-click on the badge doesn't open the photo"); + // the next double-click is apart from this one + for _ in 0..60 { + h.step(); + } + } + let badge = widget(&h, &format!("stack:{}", top.0)).unwrap().center(); + click_at(&mut h, badge); + h.settle(SETTLE); + assert!(!collapsed(&h), "a single click toggles it"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 4e397208e..36cb4edbd 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -539,6 +539,55 @@ fn crop_drag_activates_the_remembered_ratio_and_undo_restores_the_photo() { assert_eq!(develop(&h), before); } +/// Issue #991: with no aspect ever chosen, a crop starts locked to the photo's own shape (Lightroom's +/// default), so a handle drag keeps the proportions; after the Lock is switched off once, the next +/// photo's crop starts free again. +#[test] +fn a_fresh_crop_starts_locked_to_original_until_the_lock_is_switched_off() { + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = None; + h.settle(SETTLE); + let before = develop(&h); + assert!(before.crop.aspect.is_none()); + let p = h.app.session.catalog.photo(h.app.session.active().unwrap()).unwrap().clone(); + let own = (p.width * 100, p.height * 100); + assert_eq!(h.app.crop_tool_aspect(), Some(own), "the Lock shows engaged on an untouched photo"); + let drag = json!({"events": [ + {"kind": "down", "x": 0.995, "y": 0.995}, + {"kind": "drag", "x": 0.85, "y": 0.85}, + {"kind": "drag", "x": 0.75, "y": 0.75}, + {"kind": "up", "x": 0.75, "y": 0.75} + ]}); + let r = h.request("ui.pointer", drag.clone(), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(develop(&h).crop.aspect, Some(own), "the drag kept the photo's proportions"); + assert_eq!(h.app.ui.crop_default_aspect, None, "nothing was chosen explicitly"); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), before); + // Lock off once: remembered as free, and from then on the same drag crops without a ratio + let mut h = detail("panel.crop"); + h.app.ui.crop_default_aspect = None; + h.settle(SETTLE); + h.app.run("crop.aspect", json!({"aspect": "toggle"})).unwrap(); + h.settle(SETTLE); + assert_eq!(h.app.ui.crop_default_aspect, Some(json!("free"))); + assert_eq!(h.app.crop_tool_aspect(), None); + let before = develop(&h); + // a rotating drag outside the box (any crop drag starts cropping); the release on its own + for events in [ + json!([{"kind": "down", "x": -0.05, "y": 0.2}, {"kind": "drag", "x": -0.05, "y": 0.25}, {"kind": "drag", "x": -0.05, "y": 0.3}]), + json!([{"kind": "up", "x": -0.05, "y": 0.3}]), + ] { + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_ne!(develop(&h).crop, before.crop, "the drag cropped"); + assert_eq!(develop(&h).crop.aspect, None, "and left the ratio free"); + let _ = drag; +} + /// A remembered ratio the crop command refuses (a hand-edited settings file) is forgotten, and the /// drag still crops: it must not block every crop of an untouched photo. #[test] diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index 9e0b85b78..5327412d1 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -155,6 +155,11 @@ fn offline_originals_never_block_a_frame() { h.app.session.execute("library.source", &json!({"kind": "missing"})).unwrap(); assert_eq!(h.app.session.source, LibrarySource::Missing); frames_while_asleep(&mut h, &nas, 20, "Missing Photos"); + // the probe runs on the background worker: give it time to start on a loaded machine + let asked = std::time::Instant::now(); + while calls.n.load(Ordering::SeqCst) == 0 && asked.elapsed() < std::time::Duration::from_secs(5) { + std::thread::sleep(std::time::Duration::from_millis(10)); + } assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked"); assert_eq!(h.app.session.visible().len(), 0, "unknown files count as there"); diff --git a/crates/ui-egui/src/tests_profile_menu.rs b/crates/ui-egui/src/tests_profile_menu.rs new file mode 100644 index 000000000..3bd644467 --- /dev/null +++ b/crates/ui-egui/src/tests_profile_menu.rs @@ -0,0 +1,52 @@ +//! #2059: the profile dropdown and its submenus stay inside the window. A folder of many imported +//! profiles used to make its submenu run past the window's bottom edge, and the profiles below it +//! could not be reached. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(5); +const SETTLE: Duration = Duration::from_secs(10); +const FOLDER: &str = "Imported"; + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn go(h: &mut Headless, method: &str, id: &str) { + let r = h.request(method, json!({"id": id}), T); + assert_eq!(r["ok"], true, "{method} {id}: {r}"); + h.settle(SETTLE); +} + +#[test] +fn a_folder_of_many_imported_profiles_scrolls_inside_the_window() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + for i in 0..80 { + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: format!("lut:imported-{i}"), + name: format!("Imported {i}"), + group: FOLDER.into(), + file: String::new(), + }); + } + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + go(&mut h, "ui.clickWidget", "dropdown:profile"); + let menu = widget(&h, "menu-level:1").expect("the profile menu is open"); + assert!(menu.bottom() <= h.size.y + 0.5, "the profile menu {menu:?} runs past the window's bottom {}", h.size.y); + go(&mut h, "ui.hoverWidget", &format!("profileMenu:lutGroupMenu:{FOLDER}")); + let sub = widget(&h, "menu-level:2").expect("the imported folder's submenu is open"); + assert!(sub.bottom() <= h.size.y + 0.5, "the submenu {sub:?} runs past the window's bottom {}", h.size.y); + // Its 80 rows don't all fit: some are scrolled out of view rather than drawn off screen. + let rows: Vec = (0..80).filter_map(|i| widget(&h, &format!("profileMenu:group:lut:imported-{i}"))).collect(); + assert!(!rows.is_empty(), "no imported profile rows drawn"); + assert!(rows.iter().any(|r| !sub.contains_rect(*r)), "every row fits a 900 pt window"); +} diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests_recheck.rs index 88149e464..4d808f736 100644 --- a/crates/ui-egui/src/tests_recheck.rs +++ b/crates/ui-egui/src/tests_recheck.rs @@ -78,13 +78,207 @@ fn recheck_result_is_dropped_after_switching_libraries() { assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched"); } -/// The notice's Reload from Disk re-reads the file and says what came of it. +/// The notice's Reload from Disk re-reads the file in the background (a row in the activity stack, +/// the window not waiting for the decode) and says what came of it. #[test] fn notice_reload_button_rereads_the_file() { let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-reload-{}", std::process::id()))); let (mut app, id) = app_with_stale_photo(&dir.0); let ctx = egui::Context::default(); crate::panels::edit::reload_preview_only(&mut app, &ctx, id); + let rows = app.session.activity.list(); + let row = rows.iter().find(|r| r.kind == "reload").unwrap_or_else(|| panic!("no reload row: {rows:?}")); + assert_eq!(row.label, crate::reload::LABEL); + assert!(row.cancellable); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert!(app.session.activity.list().iter().all(|r| r.kind != "reload"), "the row goes with the task"); assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast); + // one undo step + assert_eq!(app.session.undo.last().map(|u| u.label.as_str()), Some("Reload Changed Files")); +} + +/// Reload from Disk on a selection returns at once and reads the files on a worker thread; `wait` +/// runs it in place; a cancelled reload changes nothing it didn't read. +#[test] +fn reload_of_a_selection_runs_in_the_background() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-sel-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + app.session.selection = lightcraft_engine::Selection { ids: vec![id], active: Some(id) }; + let r = app.run("photo.reload", json!({})).unwrap(); + assert_eq!(r, json!({"background": true, "count": 1})); + assert!(app.tasks.is_running(crate::reload::LABEL)); + assert!(app.session.activity.list().iter().any(|r| r.kind == "reload")); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); + + // agents can wait for the result: the engine's reply, nothing left running + let r = app.run("photo.reload", json!({"ids": [id.0], "wait": true})).unwrap(); + assert!(r.get("reloaded").is_some(), "{r}"); + assert!(app.tasks.is_empty()); + + // cancelled before any file is read: nothing changes + let p = app.session.catalog.photo(id).unwrap().clone(); + app.session + .catalog + .apply(lightcraft_catalog::Op::SetContent { + id, + width: p.width, + height: p.height, + file_size: p.file_size, + content_hash: p.content_hash.clone(), + preview_only: Some("Cr3 files are not decoded yet".into()), + preview_checked: "0.5.0".into(), + }) + .unwrap(); + let items = lightcraft_engine::cmd::reload::plan(&mut app.session, &json!({"ids": [id.0]})).unwrap(); + let probe = lightcraft_engine::cmd::reload::file_probe(&app.session); + let cancelled = std::sync::atomic::AtomicBool::new(true); + let results = lightcraft_engine::cmd::reload::probe(probe.as_ref(), &items, &cancelled, &|_| {}); + assert!(lightcraft_engine::cmd::reload::apply(&mut app.session, items, results).unwrap().is_empty()); + assert!(app.session.catalog.photo(id).unwrap().preview_only.is_some()); +} + +/// With no photo selected the command is refused at once, as before (no task, no row). +#[test] +fn reload_with_nothing_selected_starts_nothing() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-none-{}", std::process::id()))); + let (mut app, _) = app_with_stale_photo(&dir.0); + app.session.selection = lightcraft_engine::Selection::default(); + let _ = app.run("photo.reload", json!({})); + assert!(app.tasks.is_empty()); + assert!(app.session.activity.list().iter().all(|r| r.kind != "reload")); +} + +/// Two Reloads of the same photo overlap: the first is held after reading the original file, the +/// file is replaced and a second Reload finishes. The first one finishing afterwards must not put +/// back the old dimensions and content hash. +#[test] +fn an_older_reload_does_not_replace_a_newer_one() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Condvar, Mutex}; + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-order-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + let native = app.session.media.file_probe.clone().unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + // (the first probe has read its file, the first probe may return) + let gate = Arc::new((Mutex::new((false, false)), Condvar::new())); + let (worker_gate, worker_calls) = (gate.clone(), calls.clone()); + app.session.media.file_probe = Some(Arc::new(move |path| { + let info = native(path); + if worker_calls.fetch_add(1, Ordering::SeqCst) == 0 { + let mut state = worker_gate.0.lock().unwrap(); + state.0 = true; + worker_gate.1.notify_all(); + let _ = worker_gate.1.wait_timeout_while(state, Duration::from_secs(10), |s| !s.1).unwrap(); + } + info + })); + app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + let (state, _) = gate.1.wait_timeout_while(gate.0.lock().unwrap(), Duration::from_secs(5), |s| !s.0).unwrap(); + assert!(state.0, "the first reload read the original file"); + drop(state); + + // an external editor saves new pixels and the user reloads again + let image = lightcraft_raster::Rgba8::from_fn(40, 20, |x, y| [(x * 3) as u8, (y * 5) as u8, 90, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.0.join("files/one.png"), lightcraft_engine::export::encode_image(&image, &o).unwrap()).unwrap(); + app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while app.session.catalog.photo(id).is_some_and(|p| (p.width, p.height) != (40, 20)) { + crate::tasks::poll(&mut app, &ctx); + assert!(std::time::Instant::now() < deadline, "the second reload finishes while the first is held"); + std::thread::sleep(Duration::from_millis(5)); + } + let newer_hash = app.session.catalog.photo(id).unwrap().content_hash.clone(); + + // the first reload finishes last + gate.0.lock().unwrap().1 = true; + gate.1.notify_all(); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + let after = app.session.catalog.photo(id).unwrap(); + assert_eq!((after.width, after.height), (40, 20), "the older reload put back the old dimensions"); + assert_eq!(after.content_hash, newer_hash); +} + +/// A background Reload commits like the command: with automatic XMP writing on, metadata the file +/// newly gives (here GPS) reaches the sidecar too. +#[test] +fn a_background_reload_writes_the_sidecar_with_auto_write_on() { + use std::sync::Arc; + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-xmp-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + app.run("library.xmpPreferences", json!({"autoWrite": true})).unwrap(); + app.run("photo.rate", json!({"ids": [id.0], "rating": 1})).unwrap(); + let sidecar = dir.0.join("files/one.xmp"); + assert!(!std::fs::read_to_string(&sidecar).unwrap().contains("GPSLatitude")); + let native = app.session.media.file_probe.clone().unwrap(); + app.session.media.file_probe = Some(Arc::new(move |path| { + let mut info = native(path)?; + // GPS the catalog doesn't have yet (as for a photo catalogued by an older version) + info.meta.gps = Some((41.0, 2.0)); + Ok(info) + })); + let r = app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["background"], true); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert_eq!(app.session.catalog.photo(id).unwrap().meta.gps, Some((41.0, 2.0))); + assert!(std::fs::read_to_string(&sidecar).unwrap().contains("GPSLatitude"), "the sidecar was not written"); +} + +/// Bad `ids` are refused naming the command once, as the engine's own reply does (`wait: true`). +#[test] +fn reload_with_bad_ids_names_the_command() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-bad-{}", std::process::id()))); + let (mut app, _) = app_with_stale_photo(&dir.0); + let background = app.run("photo.reload", json!({"ids": "x"})).unwrap_err(); + let waited = app.run("photo.reload", json!({"ids": "x", "wait": true})).unwrap_err(); + assert_eq!(background, waited); + assert!(background.contains("`photo.reload`"), "{background}"); + assert!(!background.contains("``") && !background.starts_with("photo.reload:"), "{background}"); +} + +/// A Reload stopped from the activity stack before its file was read says nothing (the raw wasn't +/// tried again); one stopped after the file was read still says what came of it. +#[test] +fn a_cancelled_reload_reports_only_what_it_did() { + use std::sync::{Arc, Condvar, Mutex}; + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-cancel-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + let native = app.session.media.file_probe.clone().unwrap(); + for read in [false, true] { + // (the probe has started, the probe may return) + let gate = Arc::new((Mutex::new((false, false)), Condvar::new())); + let worker_gate = gate.clone(); + let native = native.clone(); + app.session.media.file_probe = Some(Arc::new(move |path| { + let mut state = worker_gate.0.lock().unwrap(); + state.0 = true; + worker_gate.1.notify_all(); + let _ = worker_gate.1.wait_timeout_while(state, Duration::from_secs(10), |s| !s.1).unwrap(); + // a read the cancel stopped comes back as an error, as a file not reached does + if read { native(path) } else { Err(format!("{path}: cancelled")) } + })); + app.ui.toast = None; + app.run("photo.reload", json!({"ids": [id.0]})).unwrap(); + let (state, _) = gate.1.wait_timeout_while(gate.0.lock().unwrap(), Duration::from_secs(5), |s| !s.0).unwrap(); + assert!(state.0, "the reload started reading"); + drop(state); + let row = app.session.activity.list().into_iter().find(|r| r.kind == "reload").unwrap(); + app.session.activity.cancel(row.id).unwrap(); + gate.0.lock().unwrap().1 = true; + gate.1.notify_all(); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + if read { + assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); + assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast); + } else { + assert!(app.session.catalog.photo(id).unwrap().preview_only.is_some()); + assert!(app.ui.toast.is_none(), "a cancelled reload reported {:?}", app.ui.toast); + } + } } diff --git a/docs/ai-masks.md b/docs/ai-masks.md index b02912abc..b01fd9e42 100644 --- a/docs/ai-masks.md +++ b/docs/ai-masks.md @@ -2,7 +2,8 @@ LightCraft selects objects with **SAM 3** (Segment Anything with Concepts, Meta 2025), run in pure Rust by `crates/segment` on [candle](https://github.com/huggingface/candle): on the GPU -through Metal on macOS, on the CPU elsewhere (for now). +through Metal on macOS, on the CPU elsewhere (for now). If the Mac's GPU can't run it, it runs on +the CPU (see the worker below). **The model is optional.** It is not part of LightCraft, and nothing requires it: without it, Object and Describe offer to download it, every other feature (and every existing mask, see @@ -16,7 +17,10 @@ In the Masking panel (M): to leave it out. Clicks show as green (include) and red (leave out) dots. - **Describe**: type what to select — `sky`, `trees`, `the red car` — and press Return. Every instance the model finds (score above 0.5) joins the mask; if nothing matches, nothing is - created and you are told so. + created and you are told so. Around what it found, the mask follows the model's semantic mask + for the phrase, which is sure inside and runs on where two instances touch (the instances' + own masks alone left a sky or a net about 70–90 % selected and a thin line between two people + side by side). - Several things at once: `car, road` (comma-separated) selects each and merges them. - **+ / −** under the selected mask (and the Add / Subtract / Intersect menus) combine selections: Describe `car`, click the mask, + ▸ Describe… `road`; − ▸ Describe… `people` takes @@ -214,7 +218,7 @@ wait and return the result (when built with the `sam` feature; the default CLI h `NOTICE` — the ported files say so and that they were modified): the 32-layer ViT backbone with 2-D RoPE and windowed attention, the feature pyramids, the SAM 2-style prompt encoder and two-way mask decoder for clicks, and the CLIP text encoder, DETR encoder/decoder (box - relative-position bias, presence token) and pixel decoder for text. The CLIP tokenizer is a + relative-position bias, presence token), pixel decoder and semantic head for text. The CLIP tokenizer is a small BPE in `tokenizer.rs`. The downloader (original code) is the `lightcraft-fetch` crate, shared by any model that is downloaded; `fetch/` here says what SAM 3 needs. - candle is pinned at 0.9.2: later releases make `candle-core` depend on `tokenizers` with the Oniguruma C library, and the product is pure Rust. Weights are read with positional reads (no @@ -225,6 +229,13 @@ wait and return the result (when built with the `sam` feature; the default CLI h encoded photo; the session sends it jobs and receives results over channels, never holding a lock the model holds. Each job runs under `catch_unwind`: a panic inside candle becomes an error and the model is reloaded on the next request; busy counters are reset by drop guards. +- GPU fallback (issue #1974): a job that fails on the GPU (a panic or a candle error, e.g. an + AMD GPU in an Intel Mac whose Metal compiler crashes on a matmul kernel) frees the model and runs + once more on the CPU. If that works, the model stays on the CPU until LightCraft restarts and + the user is told once ("The GPU couldn't run the AI mask model…"); if the CPU fails too, the + error is reported and the next job tries the GPU again. Missing or damaged model files and + remote-worker errors are not retried. `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` keep + the model on the CPU from the start. - Hostile documents: a selection keeps at most 4 detail patches (when read and when rendered), each at most 1024² logits; more than 64 clicks are never sent to the model. - Accuracy: `crates/segment/tests/reference.rs` compares every stage against the Python diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 8ef8789d8..86deac24d 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -60,6 +60,7 @@ guard.detail(&file_name); | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | | Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | +| Reload from Disk | `crates/ui-egui/src/reload.rs`, `crates/engine/src/cmd/reload.rs` | a command split into plan / probe / apply so the app reads the files on a worker thread (`tasks::spawn_tracked`: progress and `Cancel::Flag`); the result is committed through `Session::execute_fn` (undo step, save, XMP auto-write), and dropped for a photo a later-started reload has already updated; `wait: true` runs it in place | | Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps | ## Testing diff --git a/docs/cr3.md b/docs/cr3.md index 79babaea4..b133dfe6a 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -127,6 +127,18 @@ tiles. All ten sensor comparisons pass. R8 HEVC preview tracks are distinguished the primary Bayer RAW; the HEVC preview itself is not decoded. Missing corpus files are reported as skipped by the tests. +In a tile with a neighbour on its right, the vertical high-pass bands of the level-1 and level-2 +low-pass columns (bands 8 and 5) are entropy coded at an odd width: an even band carries one extra +column that synthesis does not use. No other band is extended, including the last level's (band 2). +The decoder takes each band's coded width from this rule. It used to try the synthesis width and up to +two more columns and keep the first that decoded to the exact end of the stream, but two widths can +both do that and give different coefficients (#899). The rule was measured on every C-RAW plane in +`cr3_corpus.rs`: two-tile files at tile widths 2816, 3144, 3192, 3444 and 3564 (G5 X Mark II, M50, +R100, 250D, RP, R, 90D, M6 Mark II), whose left tiles extend bands 5 and 8 exactly where the rule says, +and single-tile `0x100` and `0x200` files, which never extend a band. In every plane, each band decodes +at its coded width and at no other width up to two columns wider, so all sensor hashes are unchanged. +No public file has three or more C-RAW tiles; a middle tile follows the same rule, which is unverified. + The version `0x200` QP predictor and entropy framing were checked independently. Controlled input mutations establish floor averaging of paired QP rows before the nonlinear integer table, then multiplication, division by eight, addition diff --git a/docs/gaps.md b/docs/gaps.md index 1b4a385fa..f6686e2b4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -27,7 +27,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | -| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798); offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | | 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index b74af85a2..5bd0119f9 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -43,6 +43,8 @@ Overrides (read once at launch): - else `WGPU_BACKEND` (wgpu's own variable, same names) — before #136 only the window honoured it, the compute device always used Vulkan + DX12 (+ Metal). - `LIGHTCRAFT_GPU=0`: GPU rendering off for the process (the window is unaffected). +- Both `LIGHTCRAFT_GPU=0` and `LIGHTCRAFT_GPU_BACKEND=off` also keep AI Denoise, the face models + and the AI mask model (SAM 3 on Metal) on the CPU. **DX12 shader compiler (issue #471).** Every instance (window and compute) compiles DX12 shaders with FXC (`d3dcompiler_47.dll`, part of Windows; `lightcraft_gpu::backend::backend_options`). wgpu's diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index b6c0014f0..441becd15 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -28,6 +28,11 @@ settings are imported. Missing originals remain catalogued for relinking, count separately from failures reading files that are present. Known-missing originals are not probed again. Previously browsed Local records join All Photos even when their originals are offline; their promotion is counted once and undone together with the catalog import. +An image whose file, folder or root folder record is gone from the catalog, or whose original path +is empty (Lightroom leaves these behind and ignores them), is skipped together with its virtual +copies instead of failing the whole import. The result lists them as data (`skipped`: each +image id with its reason, `missingFile`, `missingFolder`, `missingRoot`, `incompletePath` or +`masterSkipped`), also in `library.inspectLightroom`, and the desktop result window says how many in the UI language. Virtual copies count as added records too; reimport reports zero additions when all records already exist. Existing LightCraft edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import @@ -44,11 +49,16 @@ retained. Oldest managed archives are retired; legacy `.json` archives remain un `Interop/lightroom-index.json` records imported identities. Source catalogs and originals are never overwritten. -Each native `.lrcat` database and WAL file is independently limited to 1 GiB -(1,073,741,824 bytes). This bounds the current whole-file reader; referenced photo sizes and -photo counts are not part of that limit. XMP packets are capped at 16 MiB and each table at one million rows. -The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format -specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB. +Native import reads referenced database pages on demand (at most 64 KiB per page), rather than +loading the whole `.lrcat`. Database files are capped at 16 GiB and 16,777,216 pages; the WAL +remains capped at 1 GiB. Tables still have a 256 MiB encoded-payload budget and one million rows, +individual records are capped at 64 MiB, and XMP packets at 16 MiB. These limits can still reject +large catalogs with dense tables; file size alone no longer determines the memory allocation. +The open file and source path are checked again after all tables are read. Close Lightroom before +importing: metadata checks detect ordinary concurrent changes, but are not SQLite snapshot isolation. + +The in-tree, read-only Rust SQLite B-tree reader follows SQLite's public file-format +specification; it introduces no dependency or C runtime. Optional history, keyword and collection tables that exceed limits are skipped with warnings; required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed WITHOUT ROWID or virtual table produces an explicit error, handled as a warning when optional. @@ -106,3 +116,7 @@ an absolute path is not looked at: the import lists it as missing with a warning photo and its virtual copies out of the library. To import originals on a share, open the catalog from that share. A path that is not a path on this computer (a Windows drive path read on macOS or Linux) is not looked at either and stays catalogued as missing. + +## Historical local validation + +Validated in a local Linux environment after upstream reconciliation: 47 catalog-focused tests, nine pipeline spot tests, the exact sparse-catalog regression, and all eight `cargo xtask ci` stages passed. The full gate recorded 3,166 passing test executions, zero failures, and 28 ignored tests. An original synthetic sparse catalog with a required image-table page beyond 1 GiB also passed headless application import and selection; its expected missing-original placeholder retained its five-star rating. This snapshot check does not establish native-window coverage or dense-catalog performance. diff --git a/docs/localization-cs.md b/docs/localization-cs.md index 49a4d9859..52fad4542 100644 --- a/docs/localization-cs.md +++ b/docs/localization-cs.md @@ -27,11 +27,13 @@ revizí rozsahu a terminologie. Rodilý mluvčí ani profesionální překladate plurálové pravidlo se nepřidává. - Písmo Inter dodávané s aplikací obsahuje české znaky i uvozovky „…“. Další font není potřeba. -Katalog obsahuje 2 056 běžných položek a 246 formátových šablon. Každý běžný klíč existuje -v jiném katalogu nebo je používán kódem. Oproti německému katalogu má 108 dalších klíčů, +Katalog obsahuje 2 394 běžných položek a 297 formátových šablon. Každý běžný klíč existuje +v jiném katalogu nebo je používán kódem. Oproti německému katalogu má 215 dalších klíčů, všechny s doloženým použitím: popisky příkazů, menu, ovládacích prvků a nastavení, -texty HDR/exportu a obličejů a kontextové hodnoty Ano/Ne. Z toho 41 klíčů již existuje -v dalších katalozích a 67 doplňuje současné překladové lookupy. +texty HDR/exportu a obličejů a kontextové hodnoty Ano/Ne. Z toho 66 klíčů již existuje +v dalších katalozích a 149 doplňuje současné překladové lookupy. Doplnění z 18. 10. 2026 +přidalo texty novějších funkcí (prezentace, tisk, přednastavení efektů masek, zaostřovací +body) a prošlo stejnou kontrolou. Řádkové fragmenty poznámek k vydáním byly odstraněny, včetně 163 fragmentů převzatých ze sdílených katalogů; text poznámek v dialogu Co je nového používá anglický fallback. diff --git a/docs/localization-ka.md b/docs/localization-ka.md index 234ab5ee9..4d517c6ca 100644 --- a/docs/localization-ka.md +++ b/docs/localization-ka.md @@ -23,8 +23,10 @@ interface never uses Mtavruli (a test checks the whole catalog). Commands are ve are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with `{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`. -Inter has no Georgian letters and craft-fonts has no Georgian face yet, so Georgian text shows boxes -until a Georgian face is available to the UI. +Inter has no Georgian letters, so the UI draws Georgian with craft-fonts' Noto Sans Georgian (`Geor`), +like the Thai and Arabic faces: desktop release builds embed it, and a web build made with +`CRAFT_FONTS_DIR` keeps it in the WASM (`crates/engine/build.rs`). The release web build doesn't use +craft-fonts yet, and a build without `CRAFT_FONTS_DIR` has no Georgian face and shows boxes. ## Terminology diff --git a/docs/localization-parity.md b/docs/localization-parity.md index b7900d350..b8c458317 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** trivial (Italian: every interface string translated, native-speaker corrections; previously minor: de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-.md` @@ -65,7 +65,7 @@ that is promising but is not recorded as review, so the column says `no (communi | Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | | Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | | Ukrainian | `uk` | 2,383 / 2,398 (99%; 2026-10-12) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | -| Italian | `it` | 2,201 / 2,398 (92%, measured 2026-10-12; the rest is What's New) | Latin (Inter) | no (written with an agent; native-speaker pass pending) | partial (lower-layer errors English) | 3–5 h | +| Italian | `it` | 2,628 / 3,278 (80%, measured 2026-10-15; every interface string is translated, the other 650 are What's New lines, left in English) | Latin (Inter) | partly (written with an agent; corrections and the 2026-10-15 additions by a native speaker) | partial (lower-layer errors English) | 3–5 h | | Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) | Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai @@ -93,6 +93,7 @@ Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bok | Date | Change | Summary | |---|---|---| +| 2026-10-15 | trivial | Italian (`it`): 339 interface messages and 60 format strings that were missing or still in English translated (2,628 of 3,278; only What's New left in English); native-speaker corrections: Exposure → *Esposizione*, Sharpening → *Nitidezza*, Preset → *Preset*, blurry → *sfocate* | | 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English | | 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) | | 2026-10-12 | minor | Georgian (`ka`) row: every catalog message | diff --git a/docs/mcp.md b/docs/mcp.md index 00841bec8..d99bbff87 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -146,8 +146,11 @@ Each edit in a batch has its own undo step. Connect mode also lists `ui_inspect` Ports and connect mode are unchanged. Every listed tool has a title and all four MCP hints. Generated command tools are conservatively -marked as edits. Helpers with optional output paths are marked as writers; `export` is not -idempotent because the default conflict policy chooses a new filename on repeated calls. +marked as edits. `export`, `render_photo` and `screenshot` have `readOnlyHint: false` and +`destructiveHint: true`: their caller-supplied output paths can replace existing files, even +when a path is optional. The image-only `render_preview` and `ui_screenshot` remain read-only +and non-destructive. These annotations are client hints, not a file-access sandbox. +`export` is not idempotent because the default conflict policy chooses a new filename on repeated calls. Unknown helper argument keys return JSON-RPC `-32602` naming the key and accepted arguments. Generated `cmd_*` tools and nested command params remain free-form: the registry has prose parameter docs, not machine-readable schemas. Existing command validation (including strict diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 04aecc480..3dbacc392 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -80,9 +80,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back, each on its own from the row's list when several are hidden, or all at once with Restore All (issue #2114); a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared primaries and transfer curve (linear, sRGB, BT.709 or a power; PQ / HLG stay preview-only) without clipping or gamut mapping, checked on one real file with 108 lossy tiles and on 2 Lightroom Classic 15.1 lossy LinearRaw DNGs (ILCE-7CM2) whose tiles declare Rec. 2100 primaries with the sRGB curve, which were preview-only before, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | @@ -103,11 +103,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | | LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | -| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | +| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file; a pick shows as a filled flag on thumbnails, in Compare, in the loupe and on the Pick button, a reject outlined (#1013) | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | -| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse (a click or double-click on a grid badge toggles the stack once, #2233), auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | | LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | @@ -131,7 +131,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went clearly over the 16 ms slider budget (more than 20 ms) in two drags in a row (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it, and a host frame time over 1 s (a hidden or stalled window) counts as unknown. A slow verdict is per photo and control (the same control on another photo, or another control on this one, is timed afresh) and expires: after 30 s a drag tries full size again, the wait doubling after each slow retry up to 240 s. Only a drag timed at full size changes it: one whose full-size drafts' median time (leaving out the first) is at most 20 ms forgets it; a drag that drafts small from its start (while the verdict holds, or where the GPU does not draw the view) or times fewer than two full-size drafts leaves it unchanged. At most 256 (photo, control) pairs are remembered, the oldest forgotten first. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays that need whole-frame context (Visualize Spots, Point Color range, HDR range) and photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | | LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | @@ -170,7 +170,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -220,7 +220,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | | LR-EDIT-GEOM-UPRIGHT | Upright | P1 | ✅ | `cmd:geometry.upright`, `cmd:geometry.guides` | | | LR-EDIT-GEOM-MANUAL | Manual transform | P1 | ✅ | `ctl:geometry.*` | | -| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`) | | +| LR-EDIT-GEOM-CONSTRAIN | Constrain crop | P1 | ✅ | `crates/ui-egui/src/panels/right.rs` (checkbox → `cmd:develop.merge`), `crates/engine/src/lib.rs` (`fit_constrained_crop`) | Ticking it, or changing Geometry, Upright or lens corrections while it is ticked, shrinks the stored crop about its centre until no blank corner is left, so the Crop tool shows it (it used to apply at render time only, outside the Crop tool: issue #1927; tests in `crates/engine/src/tests_constrain_crop.rs`). A crop drawn by hand is kept as drawn; unticking keeps the tighter crop | | LR-EDIT-GEOM-GRID | Grid while transforming | P2 | ✅ | `crates/ui-egui/src/panels/detail.rs` | a fine grid over the photo while a geometry slider is dragged | | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | @@ -238,7 +238,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it; bundled ILCA-99M2 from 144 photos: 30 held-out 5.11 → 4.87, 25 of them closer); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG whose profile defines no tone curve (absent or identity `ProfileToneCurve`, with or without a `LookTable`, as Adobe Standard in Lightroom-written DNGs) gets its tone fitted to its embedded preview when that is at least 10% of the raw's long edge and 384 px (Lightroom's 1024 px previews qualify): two Lightroom Classic 15.1 lossy ILCE-7CM2 DNGs went from about 0.8 EV darker and flat to within 3% of Lightroom's mean linear RGB. A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it; bundled ILCA-99M2 from 144 photos: 30 held-out 5.11 → 4.87, 25 of them closer); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -250,7 +250,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | -| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | +| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H (a preset takes the crop's orientation, portrait after X; a custom ratio is used as entered, issue #2253), visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step; a crop starts locked to the photo's own shape until the Lock is switched off once, then as last set (#991) | | LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | @@ -293,7 +293,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-SUBJECT | Select subject | P2 | 🟡 | `cmd:mask.add` (`subject`), `crates/pipeline/src/masks.rs` | saliency heuristic, no segmentation model; a photo that carries person mattes (DNG semantic masks: iPhone ProRAW portrait / skin / hair / teeth / glasses mattes) uses them instead (synthetic files only: no corpus file carries person mattes) | | LR-MASK-SKY | Select sky | P2 | 🟡 | `cmd:mask.add` (`sky`), `crates/pipeline/src/masks.rs` (`Mattes`), `crates/raw/src/semantic.rs` | heuristic; a photo that carries a sky matte (DNG semantic mask, e.g. iPhone ProRAW) uses it instead, through orientation / crop / lens corrections, preview = export (checked on one CC0 iPhone 12 Pro ProRAW and synthetic files) | | LR-MASK-BACKGROUND | Select background | P2 | 🟡 | `cmd:mask.add` (`background`) | inverse of the subject mask (heuristic, or the photo's person mattes) | -| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | +| LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors; a job that fails on the Mac's GPU runs again on the CPU, which then serves the rest of the session, #1974), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | | LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | @@ -461,7 +461,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | @@ -481,7 +481,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | | LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests_canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | | LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese, Thai and Georgian faces in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | @@ -698,7 +698,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:`) or “This photo can't be opened” (`compare:renderFailed:`) as the loupe does | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; on-demand main-file pages up to 16 GiB with existing table/record bounds (WAL still capped at 1 GiB, issue #798); background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). An image whose file, folder or root folder record is missing from the catalog, or whose original path is empty, is skipped with its virtual copies instead of failing the whole import; the report lists them as data (`skipped`: image id and reason) and the result window counts them in the UI language (PR #2230). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | @@ -737,7 +737,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-MAP-LOCATIONS | Saved locations | P2 | ⬜ | | | | LRC-MAP-TRACKLOG | GPS track logs | P2 | ✅ | `cmd:photo.autoTagTracklog`, `cmd:photo.tagFromTracklog`, `crates/meta/src/gpx.rs` | Photo ▸ Auto-Tag from Tracklog…: a GPX 1.0 / 1.1 track log sets the GPS of the selected photos by capture time — interpolated between the points of a track segment, else the nearest point within `maxGap` (10 min); never across segment breaks. The camera's time zone comes from the photo (Exif offset) or is asked for; photos that already have a location keep it unless `replace`; `dryRun` previews; one undo step. No track drawn on a map (no Map module) | | LRC-MAP-FILTER | Location filter bar | P2 | ⬜ | | | -| LRC-MAP-REVGEO | Reverse geocoding | P2 | 🟡 | `cmd:metadata.reverseGeocode` | offline from GeoNames (City / State / Country, no sublocation); no online provider | +| LRC-MAP-REVGEO | Reverse geocoding | P2 | 🟡 | `cmd:metadata.reverseGeocode` | offline from GeoNames (City / State / Country, no sublocation); the list holds towns of 15 000 people or more, so a smaller town gets the nearest larger one (the tooltip and command help say so); `maxDistance` leaves City empty past a distance and names the town as `nearestCity` (issue #2242); no online provider | | LRC-BOOK-SETTINGS | Book settings | P2 | ⬜ | | | | LRC-BOOK-AUTOLAYOUT | Book auto layout | P2 | ⬜ | | | | LRC-BOOK-PAGE | Book pages & templates | P2 | ⬜ | | | @@ -798,6 +798,10 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-20 | minor | LR-PRE-PANEL: the preset applied to a photo is marked again (with its Amount slider) on returning to the photo, until the photo is edited further (#2392); status unchanged (✅) | +| 2026-10-20 | patch | LR-VIEW-ZOOM: the 20 ms rule clears a slow-drag verdict only on a drag timed at full size; a drag that drafts small leaves it unchanged, as the code does; status unchanged | +| 2026-10-19 | patch | LR-VIEW-ZOOM: the slow slider-drag verdict is described as per photo and control, as the code keys it; status unchanged | +| 2026-10-15 | minor | LR-VIEW-ZOOM: a slow slider-drag verdict needs two drags over 20 ms and expires after 30 s (doubling to 240 s); hidden-window frame times are ignored; the slow set forgets its oldest entry, not all (issue #330); status unchanged | +| 2026-10-15 | minor | LR-BEHAV-BG-TASKS: Reload from Disk reads its files on a worker thread with a cancellable activity row instead of freezing the window on a selection of raws; its result is committed through the command wrapper (XMP sidecars auto-written as before) and an older reload finishing late no longer overwrites a newer one; status unchanged (✅) | | 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | | 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | | 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | @@ -892,3 +896,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | +| 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 08e70732d..99df5fc9a 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,6 +1,6 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** patch (X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by @@ -155,7 +155,7 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus | SRW | uncompressed, 12-bit packed | ✅ | EX1 corpus; NX10, NX20 | | SRW | compressed 32770 / 32772 / 32773 | ⬜ | refused | | DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) | -| DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | +| DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files; lossy tiles keep their declared transfer curve (Lightroom Classic 15.1 LinearRaw DNGs label linear data with the sRGB curve); PQ / HLG tiles preview-only | | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | | DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | @@ -288,6 +288,8 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| | 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback | +| 2026-10-15 | minor | DNGs with a look table and no tone curve (Lightroom-written, Adobe Standard) fit their tone to the embedded preview, also Lightroom's 1024 px one: two lossy ILCE-7CM2 DNGs no longer render 0.8 EV dark and flat | +| 2026-10-15 | minor | Lossy JPEG XL DNG tiles that declare a non-linear SDR transfer curve (sRGB, BT.709, DCI, gamma) decode with that curve restored instead of falling back to the embedded preview: Lightroom Classic 15.1 LinearRaw DNGs (Rec. 2100 primaries, sRGB curve; 2 private ILCE-7CM2 files) | | 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged | | 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | | 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | diff --git a/docs/web.md b/docs/web.md index b275e837d..98d0dd91c 100644 --- a/docs/web.md +++ b/docs/web.md @@ -111,7 +111,9 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not grant it, a notice says the library may be evicted under storage pressure. - **File ▸ Back Up Library…** downloads a zip of the library files and every stored original (`originals//`, so an unzipped backup is browsable; entries are stored, not - compressed; at most 4 GB). **File ▸ Restore Library from Backup…** reads such a zip into a + compressed; at most 4 GB). While a save is failing it refuses to start and says how many + changes are unsaved and why: the zip copies the stored library files, so it would miss them + (issue #1508). **File ▸ Restore Library from Backup…** reads such a zip into a new library folder (`library-restored-