diff --git a/README.md b/README.md index ba094749c..1e9291be5 100644 --- a/README.md +++ b/README.md @@ -451,6 +451,36 @@ information combined with LTO and one codegen unit on low-memory builders. A PKG its own profile settings can override inherited variables; adjust the packaging recipe locally or contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence. +**Linux/Arch build troubleshooting: rustc SIGILL with ThinLTO.** +In [#2811](https://github.com/storytold/lightcraft/issues/2811), an Arch Linux x86_64 build on an +AMD Ryzen 9 7900X3D with 64 GB RAM failed with rustc 1.99.0 / LLVM 23.1.1, AUR `lightcraft` +`0.7.1-1` and `0.13.0-1`, `RUSTFLAGS="-C opt-level=3 -C target-cpu=native"`, and release settings +`opt-level=3`, `lto=thin`, `codegen-units=1`. `cargo build --frozen --release` reached the executable +crates before rustc crashed with signal 4 (`SIGILL` / `ILL_ILLOPN`). The core dumps point into LLVM's +SelectionDAG / vector-combine optimization path; the exact compiler root cause has not been isolated. +This does not mean every machine with that hardware or toolchain will fail. It is a different observed +failure from the 16 GB OOM kills in [#1196](https://github.com/storytold/lightcraft/issues/1196) above; +there is no evidence establishing a shared root cause. + +For a direct Cargo source build, the equivalent command-local LTO override is: + +```sh +CARGO_PROFILE_RELEASE_LTO=off cargo build --frozen --release +``` + +Reporter @rwarmstr verified the workaround by exporting `CARGO_PROFILE_RELEASE_LTO=off` **inside the +AUR PKGBUILD's `_srcenv()` function** and rebuilding both package versions, without changing +`RUSTFLAGS`. The reporter tested the AUR recipe, not the direct Cargo invocation above. +A variable prefixed to `makepkg` alone may be overwritten by the PKGBUILD's own exports; the recipe +must honor or set the override. The AUR `!lto` opt-out behavior is tracked separately in +[#2813](https://github.com/storytold/lightcraft/issues/2813). + +[Cargo's LTO documentation](https://doc.rust-lang.org/cargo/reference/profiles.html#lto) distinguishes +`false` from `off`: `false` can still perform thin local LTO across codegen units (unless +`codegen-units=1` or `opt-level=0`); `off` disables LTO explicitly. The low-memory recipe above is +unchanged. This workaround is for affected builds, not a new default; disabling LTO may change +performance, binary size and build behavior. + Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU diff --git a/README.zh-hans.md b/README.zh-hans.md index fa782ba10..fd4f17132 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -384,6 +384,34 @@ CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false 设置可以覆盖继承的环境变量;应在本地调整打包配方或联系维护者,而不要假定给 `makepkg` 添加环境变量前缀就能优先生效。 +**Linux/Arch 构建故障:rustc 在 ThinLTO 阶段发生 SIGILL。** +在 [#2811](https://github.com/storytold/lightcraft/issues/2811) 中,报告者的 Arch Linux x86_64 +机器配备 AMD Ryzen 9 7900X3D 和 64 GB RAM,使用 rustc 1.99.0 / LLVM 23.1.1、AUR +`lightcraft` `0.7.1-1` 和 `0.13.0-1`、`RUSTFLAGS="-C opt-level=3 -C target-cpu=native"`, +以及 release 设置 `opt-level=3`、`lto=thin`、`codegen-units=1`。 +`cargo build --frozen --release` 在编译到可执行 crate 时,rustc 因 signal 4 +(`SIGILL` / `ILL_ILLOPN`)崩溃。core dump 指向 LLVM 的 SelectionDAG / vector-combine +优化路径;编译器的具体根因尚未确定。这不表示所有使用这些硬件或工具链的机器都会失败。 +此故障与上文 [#1196](https://github.com/storytold/lightcraft/issues/1196) 中 16 GB 机器上的 +OOM 终止表现不同,没有证据表明两者根因相同。 + +直接使用 Cargo 从源码构建时,等价的单条命令 LTO 覆盖方式是: + +```sh +CARGO_PROFILE_RELEASE_LTO=off cargo build --frozen --release +``` + +报告者 @rwarmstr 验证成功的是:在 **AUR PKGBUILD 的 `_srcenv()` 函数内部**导出 +`CARGO_PROFILE_RELEASE_LTO=off` 后重新构建上述两个版本,无需更改 `RUSTFLAGS`。 +报告者测试的是 AUR 配方,并未单独验证上面的直接 Cargo 命令。仅在 `makepkg` 前添加变量, +可能会被 PKGBUILD 自己的 export 覆盖;打包配方必须保留或设置该覆盖值。 +AUR 的 `!lto` 选项行为由 [#2813](https://github.com/storytold/lightcraft/issues/2813) 单独跟踪。 + +[Cargo 的 LTO 文档](https://doc.rust-lang.org/cargo/reference/profiles.html#lto) 区分了 `false` +和 `off`:`false` 仍可能在代码生成单元之间执行 thin local LTO(除非 `codegen-units=1` +或 `opt-level=0`);`off` 则明确禁用 LTO。上文的低内存配方保持不变。 +此绕过方法仅针对受影响的构建,不是新的默认建议;禁用 LTO 可能改变性能、二进制大小和构建行为。 + **中文和日文文字**需要共享字体仓库,这是一个可选的构建输入(所有正式发布版都会包含它): ```sh diff --git a/README.zh-hant.md b/README.zh-hant.md index 07ac908d2..ab742f905 100644 --- a/README.zh-hant.md +++ b/README.zh-hant.md @@ -402,6 +402,34 @@ CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_DEBUG=0 CARGO_PROFILE_RELEASE_LTO=false PKGBUILD 可能會覆寫繼承的環境變數;請在本機調整打包腳本或聯絡維護者,而不要假設替 `makepkg` 加上環境變數前綴就能優先生效。 +**Linux/Arch 建置故障:rustc 在 ThinLTO 階段發生 SIGILL。** +在 [#2811](https://github.com/storytold/lightcraft/issues/2811) 中,回報者的 Arch Linux x86_64 +機器配備 AMD Ryzen 9 7900X3D 與 64 GB RAM,使用 rustc 1.99.0 / LLVM 23.1.1、AUR +`lightcraft` `0.7.1-1` 與 `0.13.0-1`、`RUSTFLAGS="-C opt-level=3 -C target-cpu=native"`, +以及 release 設定 `opt-level=3`、`lto=thin`、`codegen-units=1`。 +`cargo build --frozen --release` 在編譯到可執行 crate 時,rustc 因 signal 4 +(`SIGILL` / `ILL_ILLOPN`)當機。core dump 指向 LLVM 的 SelectionDAG / vector-combine +最佳化路徑;編譯器的具體根因尚未確定。這不表示所有使用這些硬體或工具鏈的機器都會失敗。 +此故障與上文 [#1196](https://github.com/storytold/lightcraft/issues/1196) 中 16 GB 機器上的 +OOM 終止表現不同,沒有證據顯示兩者根因相同。 + +直接使用 Cargo 從原始碼建置時,等價的單條指令 LTO 覆寫方式是: + +```sh +CARGO_PROFILE_RELEASE_LTO=off cargo build --frozen --release +``` + +回報者 @rwarmstr 驗證成功的是:在 **AUR PKGBUILD 的 `_srcenv()` 函式內部**匯出 +`CARGO_PROFILE_RELEASE_LTO=off` 後重新建置上述兩個版本,無須變更 `RUSTFLAGS`。 +回報者測試的是 AUR 腳本,並未單獨驗證上面的直接 Cargo 指令。僅在 `makepkg` 前加上變數, +可能會被 PKGBUILD 自己的 export 覆寫;打包腳本必須保留或設定該覆寫值。 +AUR 的 `!lto` 選項行為由 [#2813](https://github.com/storytold/lightcraft/issues/2813) 另行追蹤。 + +[Cargo 的 LTO 文件](https://doc.rust-lang.org/cargo/reference/profiles.html#lto) 區分了 `false` +與 `off`:`false` 仍可能在程式碼產生單元之間執行 thin local LTO(除非 `codegen-units=1` +或 `opt-level=0`);`off` 則明確停用 LTO。上文的低記憶體建置配方維持不變。 +此繞過方法僅適用於受影響的建置,不是新的預設建議;停用 LTO 可能改變效能、二進位檔大小與建置行為。 + 測試在缺少 GPU、語料庫、craft-fonts 字型或模型時會直接通過而不檢查任何內容(libtest 沒有「已跳過」 這個狀態),所以 `cargo xtask ci` 會把它們列出來:測試會用 `lightcraft_testkit::skip!` 記錄跳過原因, 執行結束時會列出機器提供的環境(作業系統、CPU、記憶體、每個後端的 GPU 配接器、語料庫、字型)以及每一項 diff --git a/contributors/people.toml b/contributors/people.toml index 7518acd38..ae3840bde 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -77,3 +77,7 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.Peaperfish] +real_name = "Yule Wang" +display_name = "Brian Wang" diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs index d3f455b81..b1c48762f 100644 --- a/crates/engine/src/doc_path.rs +++ b/crates/engine/src/doc_path.rs @@ -63,6 +63,7 @@ mod tests { assert_eq!(resolve("Photos/2024:05/a.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Invalid))); #[cfg(not(windows))] assert_eq!(resolve(r"C:\Photos\a.jpg", Some(&doc)), Err(NotFollowed::NotHere)); + #[cfg(not(windows))] let gvfs = Path::new("/run/user/1000/gvfs/smb-share:server=tokyo,share=photos/catalog.lrcat"); #[cfg(not(windows))] assert_eq!(resolve("a.jpg", Some(gvfs)), Ok(gvfs.with_file_name("a.jpg"))); diff --git a/crates/engine/src/export/prepare.rs b/crates/engine/src/export/prepare.rs index 0a3dce119..d71a60e0b 100644 --- a/crates/engine/src/export/prepare.rs +++ b/crates/engine/src/export/prepare.rs @@ -227,9 +227,22 @@ impl PreparedExport { if remove_location { let metadata = lightcraft_meta::try_extract(&bytes) .map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?; - if metadata.gps.is_some() { + if !bytes.starts_with(b"8BPS") { + let embedded = lightcraft_meta::embedded(&bytes); + if let Some(xmp) = embedded.xmp.as_deref() { + lightcraft_meta::parse_xmp(xmp).map_err(|e| { + format!("{label}: can't verify location metadata in this original file: malformed embedded XMP: {e}") + })?; + } + } + let embeds_place_name = + [metadata.sublocation.as_deref(), metadata.city.as_deref(), metadata.state.as_deref(), metadata.country.as_deref()] + .into_iter() + .flatten() + .any(|value| !value.trim().is_empty()); + if metadata.gps.is_some() || embeds_place_name { return Err(format!( - "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" + "{label}: can't remove the location from an original file that embeds GPS or place names; export as JPEG or TIFF, or as DNG for a raw photo" )); } } diff --git a/crates/engine/src/export/tests.rs b/crates/engine/src/export/tests.rs index 139939a1d..b8482a7df 100644 --- a/crates/engine/src/export/tests.rs +++ b/crates/engine/src/export/tests.rs @@ -916,6 +916,51 @@ fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) { (v.len(), d) } +/// A 16-bit render of a photo-like scene: smooth colour gradients plus sensor-like noise of +/// `noise` (linear, about 0.002 to 0.03 for clean to high-ISO raws), the case where the low +/// byte of every 16-bit sample is mostly noise. +fn noisy_render(noise: f32) -> lightcraft_pipeline::Rendered { + let (w, h) = (512, 384); + // a hash of the position and channel: the same noise every run + let hash = |x: usize, y: usize, c: usize| { + let mut z = (x as u64) << 32 ^ (y as u64) << 2 ^ c as u64 ^ 0x2882; + z = (z ^ (z >> 33)).wrapping_mul(0xff51_afd7_ed55_8ccd); + z = (z ^ (z >> 33)).wrapping_mul(0xc4ce_b9fe_1a85_ec53); + (z ^ (z >> 33)) as f32 / u64::MAX as f32 - 0.5 + }; + let src = lightcraft_raster::Rgb32f::from_fn(w, h, |x, y| { + let (u, v) = (x as f32 / w as f32, y as f32 / h as f32); + let base = [0.05 + 0.4 * u, 0.1 + 0.3 * v, 0.2 + 0.2 * (u * v)]; + [0, 1, 2].map(|c| (base[c] + noise * hash(x, y, c)).max(0.0)) + }); + let req = lightcraft_pipeline::RenderRequest { depth: OutputDepth::U16, ..lightcraft_pipeline::RenderRequest::fit(w, h) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req) +} + +/// Issue #2882: a 16-bit TIFF with ZIP (the default) is never larger than the same render +/// uncompressed, from smooth to noisy. (LZW can be: it is printed, not checked, and the export +/// dialog says so when it is picked.) +#[test] +fn sixteen_bit_zip_tiff_is_never_larger_than_uncompressed() { + assert_eq!(ExportOptions::default().tiff_compression, TiffCompression::Deflate, "ZIP is the default"); + for noise in [0.0, 0.002, 0.01, 0.03, 0.1] { + let r = noisy_render(noise); + assert!(matches!(r.deep.as_ref().unwrap().samples, DeepSamples::U16(_))); + let size = |tiff_compression| { + let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(16), tiff_compression, ..Default::default() }; + assert_eq!(o.effective_depth(), OutputDepth::U16); + encode_rendered(&r, &o, None).unwrap().len() + }; + let (none, zip, lzw) = (size(TiffCompression::None), size(TiffCompression::Deflate), size(TiffCompression::Lzw)); + eprintln!( + "noise {noise}: none {none} B, ZIP {zip} B ({:+.1} %), LZW {lzw} B ({:+.1} %)", + 100.0 * (zip as f64 / none as f64 - 1.0), + 100.0 * (lzw as f64 / none as f64 - 1.0) + ); + assert!(zip <= none, "noise {noise}: 16-bit ZIP {zip} B is larger than uncompressed {none} B"); + } +} + #[test] fn sixteen_bit_tiff_and_png_have_no_banding() { let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() }; diff --git a/crates/engine/src/media/session.rs b/crates/engine/src/media/session.rs index e3f364fd9..79cfa556c 100644 --- a/crates/engine/src/media/session.rs +++ b/crates/engine/src/media/session.rs @@ -404,6 +404,7 @@ impl crate::Session { /// Synchronous render (CLI, MCP, tests). pub fn render_now(&mut self, id: PhotoId, max_w: usize, max_h: usize) -> Result { + lightcraft_pipeline::check_output_size(max_w, max_h)?; let job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; let r = job.run(); self.accept(&r); @@ -434,7 +435,17 @@ impl crate::Session { space: lightcraft_pipeline::OutputSpace, depth: lightcraft_pipeline::OutputDepth, ) -> Result { + lightcraft_pipeline::check_output_size(max_w, max_h)?; let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?; + // An export develops from a source at least twice its size, so it is resized down with a good filter + // rather than made from a preview just covering it (for a raw, a binned and box-reduced image) + let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); + let level = SourceLevel::for_size(source_edge_needed(&p, &job.settings, true, max_w.max(max_h)).saturating_mul(2)); + if level.max_edge() > job.level.max_edge() { + job.source = self.media.source_ref(&p, level); + job.key ^= ((job.level as u64) << 60) ^ ((level as u64) << 60); + job.level = level; + } // An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw // image that the file keeps (Lightroom's merged DNGs) let reduced = match &job.source { diff --git a/crates/engine/src/media/tests.rs b/crates/engine/src/media/tests.rs index d36284262..15744f025 100644 --- a/crates/engine/src/media/tests.rs +++ b/crates/engine/src/media/tests.rs @@ -517,3 +517,22 @@ fn variant_jobs_have_their_own_keys_and_hit_the_cache() { assert_ne!(pv.key, s.render_job(id, 800, 600, false, true).unwrap().key); assert_eq!(*pv.settings, a); } + +/// An export develops from a source at least twice its long edge (a 2000 px export of a 6000 px +/// photo from the original, not the 2560 px preview, which for a raw is binned and box-reduced), +/// while on-screen views keep the cheap preview. +#[test] +fn an_export_develops_from_a_source_twice_its_size() { + use lightcraft_pipeline::{OutputDepth, OutputSpace}; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + assert!(p.width.max(p.height) >= 6000, "a camera-sized demo: {}×{}", p.width, p.height); + let job = s.export_job(id, 2000, 2000, OutputSpace::Srgb, OutputDepth::U8).unwrap(); + assert_eq!(job.level, SourceLevel::Full, "2000 px export"); + for edge in [200, 600, 1280, 1281, 2560] { + let job = s.export_job(id, edge, edge, OutputSpace::Srgb, OutputDepth::U8).unwrap(); + assert!(job.level.max_edge() >= 2 * edge, "{edge} px export from {:?}", job.level); + } + assert_eq!(s.render_job(id, 1600, 1600, false, true).unwrap().level, SourceLevel::Preview, "a view keeps the preview"); +} diff --git a/crates/engine/src/tests/export.rs b/crates/engine/src/tests/export.rs index d0f174245..8a4f5392e 100644 --- a/crates/engine/src/tests/export.rs +++ b/crates/engine/src/tests/export.rs @@ -6,6 +6,14 @@ use crate::Session; use crate::export::{ExportFormat, ExportOptions, export_photo}; use crate::tests_xmp::{synthetic_dng_with, synthetic_psd_with_metadata, temp_dir}; +fn jpeg_with_xmp(metadata: &lightcraft_meta::Metadata) -> Vec { + let pixels = [96u8; 12]; + let image = lightcraft_codecs::EncodeImage::new(2, 2, 3, lightcraft_codecs::Samples::U8(&pixels)); + let xmp = lightcraft_meta::write_xmp(metadata, None); + let encoded = lightcraft_codecs::EncodeMeta { xmp: Some(&xmp), ..Default::default() }; + lightcraft_codecs::encode_jpeg(&image, 90, lightcraft_codecs::ChromaSubsampling::S444, &encoded).unwrap() +} + #[test] fn full_size_export_of_a_cropped_photo_is_not_upscaled() { let mut s = Session::with_demo(); @@ -640,6 +648,168 @@ fn remove_location_holds_for_original_and_dng_exports() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn remove_location_refuses_original_with_embedded_place_names() { + let dir = temp_dir("export-noloc-place-fields"); + let value = "Embedded Place"; + let cases = [ + ("sublocation", lightcraft_meta::Metadata { sublocation: Some(value.into()), ..Default::default() }), + ("city", lightcraft_meta::Metadata { city: Some(value.into()), ..Default::default() }), + ("state", lightcraft_meta::Metadata { state: Some(value.into()), ..Default::default() }), + ("country", lightcraft_meta::Metadata { country: Some(value.into()), ..Default::default() }), + ]; + let mut paths = Vec::new(); + for (name, metadata) in &cases { + let path = dir.join(format!("{name}.jpg")); + std::fs::write(&path, jpeg_with_xmp(metadata)).unwrap(); + paths.push(path.to_string_lossy().to_string()); + } + + let mut session = Session::new().with_fs(); + session.execute("library.import", &json!({"paths": paths})).unwrap(); + let options = ExportOptions { format: ExportFormat::Original, remove_location: true, ..Default::default() }; + for (name, _) in cases { + let photo_id = session.catalog.photos().find(|p| p.file_name == format!("{name}.jpg")).unwrap().id; + let original = std::fs::read(dir.join(format!("{name}.jpg"))).unwrap(); + let embedded = lightcraft_meta::try_extract(&original).unwrap(); + assert!(embedded.gps.is_none(), "{name} fixture should isolate place names"); + let place_name = match name { + "sublocation" => embedded.sublocation.as_deref(), + "city" => embedded.city.as_deref(), + "state" => embedded.state.as_deref(), + "country" => embedded.country.as_deref(), + _ => None, + }; + assert_eq!(place_name, Some(value), "{name} fixture should be parsed"); + let error = export_photo(&mut session, photo_id, &options, 1).err().expect("embedded place name should refuse Original export"); + assert!(error.contains("place names") && error.contains("export as JPEG or TIFF"), "{name}: {error}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn remove_location_refuses_original_with_unreadable_non_psd_xmp() { + let dir = temp_dir("export-noloc-malformed-xmp"); + let path = dir.join("Unreadable.jpg"); + let pixels = [96u8; 12]; + let image = lightcraft_codecs::EncodeImage::new(2, 2, 3, lightcraft_codecs::Samples::U8(&pixels)); + let xmp = " Result<(), String> { + writes.push(path.to_string()); + std::fs::write(path, bytes).map_err(|error| error.to_string()) + }; + let exists = |path: &str| std::path::Path::new(path).exists(); + let result = run_batch(items, &options, &destination, &mut write, &exists, true, &mut |_, _| true); + + let error = result.unwrap_err(); + assert!(error.contains("place names"), "{error}"); + assert!(writes.is_empty(), "refused export called the writer: {writes:?}"); + assert!(!output.join("City.jpg").exists()); + assert!(!output.join("City.xmp").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn remove_location_checks_embedded_places_after_catalog_clear() { + let dir = temp_dir("export-noloc-place-catalog-clear"); + let source = dir.join("City.jpg"); + let original = jpeg_with_xmp(&lightcraft_meta::Metadata { city: Some("Embedded Place".into()), ..Default::default() }); + std::fs::write(&source, &original).unwrap(); + + let mut session = Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [source.to_string_lossy()]})).unwrap(); + let id = session.catalog.photos().next().unwrap().id; + assert_eq!(session.catalog.photo(id).unwrap().meta.city, "Embedded Place"); + session.execute("photo.setMeta", &json!({"ids": [id.0], "city": ""})).unwrap(); + assert!(session.catalog.photo(id).unwrap().meta.city.is_empty()); + assert_eq!(lightcraft_meta::try_extract(&std::fs::read(&source).unwrap()).unwrap().city.as_deref(), Some("Embedded Place")); + + let options = ExportOptions { format: ExportFormat::Original, remove_location: true, ..Default::default() }; + let error = export_photo(&mut session, id, &options, 1).err().expect("embedded place name should refuse Original export"); + assert!(error.contains("place names"), "{error}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn remove_location_allows_original_without_embedded_location() { + let dir = temp_dir("export-noloc-place-controls"); + let embedded_path = dir.join("Embedded.jpg"); + let no_location_path = dir.join("NoLocation.jpg"); + let blank_path = dir.join("Blank.jpg"); + let embedded = jpeg_with_xmp(&lightcraft_meta::Metadata { city: Some("Embedded Place".into()), ..Default::default() }); + let no_location = jpeg_with_xmp(&Default::default()); + let blank = jpeg_with_xmp(&lightcraft_meta::Metadata { city: Some(" \t".into()), ..Default::default() }); + std::fs::write(&embedded_path, &embedded).unwrap(); + std::fs::write(&no_location_path, &no_location).unwrap(); + std::fs::write(&blank_path, &blank).unwrap(); + assert!(lightcraft_meta::try_extract(&blank).unwrap().city.is_none(), "whitespace-only XMP place names are blank"); + + let mut session = Session::new().with_fs(); + session + .execute( + "library.import", + &json!({"paths": [embedded_path.to_string_lossy(), no_location_path.to_string_lossy(), blank_path.to_string_lossy()]}), + ) + .unwrap(); + let photo_id = |name: &str| session.catalog.photos().find(|p| p.file_name == name).unwrap().id; + let embedded_id = photo_id("Embedded.jpg"); + let no_location_id = photo_id("NoLocation.jpg"); + let blank_id = photo_id("Blank.jpg"); + + let keep = ExportOptions { format: ExportFormat::Original, ..Default::default() }; + let kept = export_photo(&mut session, embedded_id, &keep, 1).unwrap(); + assert_eq!(kept.bytes, embedded, "removeLocation=false still copies the source byte for byte"); + assert_eq!(lightcraft_meta::try_extract(&kept.bytes).unwrap().city.as_deref(), Some("Embedded Place")); + + let remove = ExportOptions { format: ExportFormat::Original, remove_location: true, ..Default::default() }; + for (id, source) in [(no_location_id, &no_location), (blank_id, &blank)] { + let exported = export_photo(&mut session, id, &remove, 1).unwrap(); + assert_eq!(&exported.bytes, source); + } + + session.execute("photo.setMeta", &json!({"ids": [no_location_id.0], "city": "Catalog-only Place"})).unwrap(); + let catalog_only = export_photo(&mut session, no_location_id, &remove, 1).unwrap(); + let sidecar = catalog_only.sidecars.first().unwrap().1.as_slice(); + let packet = std::str::from_utf8(sidecar).unwrap(); + assert!(lightcraft_meta::parse_xmp(packet).unwrap().metadata.city.is_none(), "catalog-only place name is removed from the sidecar"); + + let _ = std::fs::remove_dir_all(&dir); +} + /// `metadata: "NONE"` as `app.export` sends it: the JPEG carries no location or camera data /// (it used to be read as `all` and carry both). #[test] @@ -1203,3 +1373,39 @@ fn same_folder_as_original_refuses_photos_without_a_folder() { assert!(e.contains("folder is missing") && e.contains(&gone), "{e}"); let _ = std::fs::remove_dir_all(&dir); } + +/// Issue #2882: the demo photo's 16-bit TIFF export (2000 px long edge) with ZIP (the default) is +/// never larger than the same export uncompressed. +#[test] +fn sixteen_bit_zip_tiff_export_is_never_larger_than_uncompressed() { + use crate::export::TiffCompression; + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let size = |s: &mut Session, tiff_compression| { + let o = ExportOptions { + format: ExportFormat::Tiff, + bit_depth: Some(16), + tiff_compression, + ..ExportOptions::from_json(&json!({"longEdge": 2000})) + }; + export_photo(s, id, &o, 1).unwrap().bytes.len() + }; + let none = size(&mut s, TiffCompression::None); + let zip = size(&mut s, TiffCompression::Deflate); + let lzw = size(&mut s, TiffCompression::Lzw); + eprintln!("demo photo, 16-bit TIFF: none {none} B, ZIP {zip} B, LZW {lzw} B"); + assert!(zip <= none, "16-bit ZIP {zip} B is larger than uncompressed {none} B"); +} + +/// An absurd export size is refused, not a multiply overflow (it panicked before the fix). +#[test] +fn huge_export_size_is_an_error() { + use lightcraft_pipeline::{OutputDepth, OutputSpace}; + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let huge = usize::MAX >> 8; + assert!(s.render_export(id, huge, huge, OutputSpace::Srgb, OutputDepth::U8).is_err()); + assert!(s.render_export(id, 70_000, 10, OutputSpace::Srgb, OutputDepth::U8).is_err()); + assert!(s.render_now(id, huge, huge).is_err()); + assert!(s.render_now(id, 10, 70_000).is_err()); +} diff --git a/crates/engine/src/tests/import.rs b/crates/engine/src/tests/import.rs index 87ead8510..478f99b1d 100644 --- a/crates/engine/src/tests/import.rs +++ b/crates/engine/src/tests/import.rs @@ -13,6 +13,9 @@ use crate::Session; fn temp_dir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("lc-import-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); + // Tests that take a folder "offline" rename it to this sibling; a left-over one (from a run + // whose process id is reused) is non-empty and would make that rename fail. + let _ = std::fs::remove_dir_all(d.with_extension("offline")); std::fs::create_dir_all(&d).unwrap(); d } @@ -39,7 +42,9 @@ fn recursive_discovery_includes_sony_sr2_and_srf_case_insensitively() { let src = temp_dir("sony-discovery"); std::fs::create_dir_all(src.join("trip/day2")).unwrap(); let names = ["r1.SR2", "f828.srf", "trip/r1.sr2", "trip/f828.SRF", "trip/day2/r1.Sr2", "trip/day2/f828.SrF", "control.JPG"]; - let mut expected: Vec = names.iter().map(|name| src.join(name).to_string_lossy().into_owned()).collect(); + // one component at a time: `join("trip/r1.sr2")` keeps the `/`, discovery yields the platform's separator + let mut expected: Vec = + names.iter().map(|name| name.split('/').fold(src.clone(), |p, c| p.join(c)).to_string_lossy().into_owned()).collect(); for path in &expected { std::fs::write(path, b"discovery only, not a decoded photo").unwrap(); } @@ -1433,6 +1438,11 @@ fn smart_previews_folder_is_chosen_per_library() { let r = again.execute("library.smartPreviewsLocation", &json!({"reset": true})).unwrap(); assert_eq!(r["custom"], false, "{r}"); assert_eq!(r["path"], lib.join("Smart Previews").to_string_lossy().as_ref()); + // (the open library's files are removed below: on Windows an open file cannot be) + drop(again); + for d in [&lib, &away, &src.with_extension("offline")] { + let _ = std::fs::remove_dir_all(d); + } } /// Missing Photos covers library photos only: a small library next to many Local browse diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index c95478388..f5ae5e410 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -137,7 +137,8 @@ impl Url { } }; let port = match port { - Some(p) => p.parse::().ok().filter(|p| *p != 0).ok_or_else(|| bad("bad port"))?, + Some(p) if p.bytes().all(|b| b.is_ascii_digit()) => p.parse::().ok().filter(|p| *p != 0).ok_or_else(|| bad("bad port"))?, + Some(_) => return Err(bad("bad port")), None if tls => 443, None => 80, }; @@ -519,7 +520,8 @@ impl Response { if *left == 0 { let line = self.conn.line(1024, limits)?; let hex = line.split(';').next().unwrap_or_default().trim(); - let size = u64::from_str_radix(hex, 16).map_err(|_| HttpError::Protocol(format!("bad chunk size `{hex}`")))?; + let size = if hex.bytes().all(|b| b.is_ascii_hexdigit()) { u64::from_str_radix(hex, 16).ok() } else { None } + .ok_or_else(|| HttpError::Protocol(format!("bad chunk size `{hex}`")))?; if size == 0 { // trailers, up to the empty line let mut n = 0; @@ -595,7 +597,10 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result().map_err(|_| HttpError::Protocol(format!("bad Content-Length `{n}`")))?) + // RFC 9110: `1*DIGIT`; `u64::from_str` alone would also take a leading '+' + let v = n.trim(); + let len = if !v.is_empty() && v.bytes().all(|b| b.is_ascii_digit()) { v.parse::().ok() } else { None }; + BodyMode::Length(len.ok_or_else(|| HttpError::Protocol(format!("bad Content-Length `{n}`")))?) } else { BodyMode::Close }; @@ -652,6 +657,29 @@ mod tests_connect { assert!(r.is_err()); assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed()); } + + /// Content-Length is `1*DIGIT`: a sign, hex prefix, trailing junk or empty value is refused, not read as a length. + #[test] + fn signed_or_malformed_content_length_is_refused() { + for value in ["+3", "-3", "3x", "", "0x10", "+0"] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = std::thread::spawn(move || { + let (mut s, _) = listener.accept().unwrap(); + let mut buf = [0u8; 1024]; + let _ = s.read(&mut buf); + let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {value}\r\n\r\nabc").as_bytes()); + }); + let cancel = AtomicBool::new(false); + let url = Url::parse(&format!("http://127.0.0.1:{port}/f")).unwrap(); + match get(&url, &[], &limits(&cancel)) { + Err(HttpError::Protocol(m)) => assert!(m.contains("bad Content-Length"), "{value:?}: {m}"), + Err(e) => panic!("{value:?}: unexpected error {e:?}"), + Ok(r) => panic!("{value:?}: malformed Content-Length accepted (status {})", r.status), + } + server.join().unwrap(); + } + } } #[cfg(test)] @@ -713,6 +741,15 @@ mod tests { } } + #[test] + fn rejects_signed_ports() { + for bad in ["http://example.com:+80/x", "http://example.com:-80/x", "http://[::1]:+80/x"] { + assert!(Url::parse(bad).is_err(), "{bad}"); + } + assert!(Url::parse("http://a.test/").unwrap().join("http://example.com:+80/x").is_err()); + assert_eq!(Url::parse("http://example.com:80/x").unwrap().port, 80); + } + #[test] fn rejects_text_after_a_bracketed_ipv6_host() { for bad in ["http://[::1]junk/x", "https://[::1]junk/x", "http://[::1]8080/x", "http://[::1]8080"] { diff --git a/crates/fetch/src/lib.rs b/crates/fetch/src/lib.rs index 12daff541..07552e4bb 100644 --- a/crates/fetch/src/lib.rs +++ b/crates/fetch/src/lib.rs @@ -466,16 +466,25 @@ fn parse_content_range(v: &str) -> Option<(u64, Option)> { let v = v.trim().strip_prefix("bytes")?.trim(); let (range, total) = v.split_once('/')?; let (a, b) = range.split_once('-')?; - let (a, b) = (a.trim().parse::().ok()?, b.trim().parse::().ok()?); + let (a, b) = (digits(a)?, digits(b)?); if b < a { return None; } let total = match total.trim() { "*" => None, - t => Some(t.parse::().ok()?), + t => Some(digits(t)?), }; Some((a, total)) } +/// A non-empty run of ASCII digits as a `u64` (no sign, unlike `str::parse`). +fn digits(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + #[cfg(test)] mod tests; diff --git a/crates/fetch/src/tests.rs b/crates/fetch/src/tests.rs index 08f869dcc..5144bfc09 100644 --- a/crates/fetch/src/tests.rs +++ b/crates/fetch/src/tests.rs @@ -86,6 +86,9 @@ fn handle(mut s: TcpStream, hits: &AtomicUsize) { b.extend_from_slice(&[7u8; 5000]); send(&mut s, "200 OK", "", &b) } + "signedchunk" => { + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n+5\r\nhello\r\n0\r\n\r\n"); + } "chunked" => { let _ = s.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n"); for c in body.chunks(70_001) { @@ -249,6 +252,16 @@ fn a_416_or_a_bad_complete_part_starts_over() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn signed_chunk_size_is_rejected() { + let srv = server(); + let dir = tmp("signedchunk"); + let f = FileSpec { name: "vocab.json", size: None, sha256: None, max: 1 << 20 }; + let r = run(&[f], &[format!("{}/signedchunk", srv.base)], &dir, &AtomicBool::new(false)).0; + assert!(r.is_err(), "a chunk size of `+5` was accepted"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn chunked_bodies_and_unpinned_files() { let srv = server(); @@ -335,6 +348,10 @@ fn content_ranges() { assert_eq!(parse_content_range("bytes 0-0/*"), Some((0, None))); assert_eq!(parse_content_range("bytes 9-1/300"), None); assert_eq!(parse_content_range("items 1-2/3"), None); + assert_eq!(parse_content_range("bytes +100-+299/+300"), None); + assert_eq!(parse_content_range("bytes 100-+299/300"), None); + assert_eq!(parse_content_range("bytes 100-299/+300"), None); + assert_eq!(parse_content_range("bytes 1-/3"), None); } /// A bearer token goes only to https, only to the host the download began at, and only when there is one. diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 90aed7974..a602b1e8d 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -599,7 +599,10 @@ pub fn render( }; let plan = lightcraft_pipeline::plan(src, info, s, req); let (w, h) = (plan.w, plan.h); - let n = w * h; + let Some(n) = w.checked_mul(h).filter(|n| n.checked_mul(12).is_some()) else { + fail(FailKind::Limit, format!("{w}×{h} output is too large")); + return None; + }; let _limit = match fault { Some(crate::Fault::Limit(bytes)) => Some(crate::ctx::LimitOverride::new(bytes)), _ => None, diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index d62a9a707..94b1d2606 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -132,7 +132,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), fit?: dimensions|widthHeight (with width + height: dimensions fits either orientation, widthHeight limits the width to `width` and the height to `height`), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip (default zip; lzw is usually larger and slower than zip, at 16 bit often larger than none: only for software that needs LZW), dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } @@ -207,7 +207,7 @@ mod tests { fn folder_discovery_matches_engine_extensions() { let dir = std::env::temp_dir().join(format!("lc-mcp-discovery-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - let nested = dir.join("trip/day2"); + let nested = dir.join("trip").join("day2"); // not "trip/day2": discovery yields the platform's separator std::fs::create_dir_all(&nested).unwrap(); let mut expected = Vec::new(); for ext in lightcraft_engine::import::EXTENSIONS { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 8f782bb9d..b542b2b2f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -238,7 +238,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "destinationMode": {"type": "string", "enum": ["specificFolder", "sameAsOriginal"], "description": "specificFolder (default) = everything into `dir`; sameAsOriginal = each photo into the folder of its original (+ `subfolder`); a photo with no folder on disk (demo, browser) or whose folder is gone gets an error"}, "subfolder": {"type": "string", "description": "Created inside the destination folder"}, "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, - "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, + "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"], "description": "zip (default); lzw is usually larger and slower than zip, and at 16 bit often larger than none: use it only when other software needs LZW"}, "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed", "lossy"]}, "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 8f2702760..42cfedbba 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -173,6 +173,21 @@ impl PixelWindow { } } +/// The longest output side a render may be asked for (TIFF/PNG/JPEG all fit within it). +pub const MAX_OUTPUT_SIDE: usize = 65_535; +/// The most output pixels a render may be asked for (the box it fits into, ≈ 1.07 Gpx). +pub const MAX_OUTPUT_PIXELS: usize = 1 << 30; + +/// Rejects an output box past [`MAX_OUTPUT_SIDE`] or [`MAX_OUTPUT_PIXELS`] before anything is +/// sized from it. +pub fn check_output_size(max_w: usize, max_h: usize) -> Result<(), String> { + let pixels = max_w.checked_mul(max_h); + if max_w > MAX_OUTPUT_SIDE || max_h > MAX_OUTPUT_SIDE || pixels.is_none_or(|n| n > MAX_OUTPUT_PIXELS) { + return Err(format!("output size {max_w}×{max_h} is too large (at most {MAX_OUTPUT_SIDE} px per side and {MAX_OUTPUT_PIXELS} pixels)")); + } + Ok(()) +} + impl RenderRequest { pub fn fit(max_w: usize, max_h: usize) -> Self { Self { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index aaafcb355..9ef12337d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -18,9 +18,10 @@ mod sam_model; pub(crate) use confirm::album_entries; pub use confirm::confirm_dialog; pub use cull::{MAX_BURST_GAP, clamp_cull_burst, cull_params, fmt_gap, parse_gap}; +pub(crate) use export::preset_lzw_note; use export::{ LIMIT_KB, QUALITY, START_NUMBER, WM_IMAGE_WIDTH, WM_INSET_X, WM_INSET_Y, WM_OPACITY, WM_POSITIONS, WM_SIZE, export_dialog_params, export_size, - group_checklist, watermark_preview, + group_checklist, tiff_lzw_note, watermark_preview, }; pub use frame::{ConfirmStyle, confirm_style, list_height, prompt}; use frame::{ @@ -1119,7 +1120,12 @@ fn export_preset_row( ui.separator(); } builtin = b; - if ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)).clicked() { + let r = ui.selectable_label(false, crate::i18n::builtin_label(&p.name, b)); + let r = match preset_lzw_note(&p.params) { + Some(note) => r.on_hover_text(crate::i18n::tr(note)), + None => r, + }; + if r.clicked() { chosen = Some(p.name); } } @@ -1386,6 +1392,12 @@ fn export_naming_rows(ui: &mut egui::Ui, opts: &mut ExportOptions) { if opts.format == F::Tiff { use lightcraft_engine::export::TiffCompression as Z; choices(ui, "Compression", "exportTiffCompression", &[(Z::None, "None"), (Z::Lzw, "LZW"), (Z::Deflate, "ZIP")], &mut opts.tiff_compression); + // issue #2882: LZW stays as picked, with a note on what it costs + if let Some(note) = tiff_lzw_note(opts) { + let t = Tokens::get(ui.ctx()); + let r = ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + crate::widgets::register(ui, "note:exportTiffLzw", r.rect); + } } if opts.format == F::Dng { use lightcraft_engine::export::DngCompression as Z; diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs index ce9cffe77..47252832b 100644 --- a/crates/ui-egui/src/dialogs/export.rs +++ b/crates/ui-egui/src/dialogs/export.rs @@ -119,6 +119,29 @@ pub(super) fn watermark_preview( crate::widgets::register(ui, "image:exportWatermarkPreview", r.rect); } +/// The 16-bit note: LZW's byte dictionary does worse on 16-bit samples than storing them, so +/// 16-bit LZW TIFFs came out 10-33 % larger than uncompressed ones (issue #2882). +pub(crate) const TIFF_LZW_NOTE_16: &str = + "At 16 bit, LZW is often larger than no compression, and larger and slower than ZIP. Use it only if other software needs LZW."; +/// The note at 8 bit and 32-bit float: one 8-bit file in #2882 was larger with LZW too. +pub(crate) const TIFF_LZW_NOTE: &str = + "LZW is usually larger and slower than ZIP, and can be larger than no compression. Use it only if other software needs LZW."; + +/// The note shown under Compression when a TIFF export uses LZW (none otherwise). The export +/// keeps LZW: some software reads only LZW TIFFs. +pub(crate) fn tiff_lzw_note(o: &lightcraft_engine::export::ExportOptions) -> Option<&'static str> { + use lightcraft_engine::export::{ExportFormat, OutputDepth, TiffCompression}; + if o.format != ExportFormat::Tiff || o.tiff_compression != TiffCompression::Lzw { + return None; + } + Some(if o.effective_depth() == OutputDepth::U16 { TIFF_LZW_NOTE_16 } else { TIFF_LZW_NOTE }) +} + +/// [`tiff_lzw_note`] for an export preset's params: the hint on presets that write LZW TIFFs. +pub(crate) fn preset_lzw_note(params: &serde_json::Value) -> Option<&'static str> { + tiff_lzw_note(&lightcraft_engine::export::ExportOptions::from_json(params)) +} + /// The Export dialog's choices as `app.export` params (without the folder). pub(super) fn export_dialog_params( opts: &lightcraft_engine::export::ExportOptions, @@ -179,11 +202,12 @@ pub(super) const QUALITY: ControlSpec = spec("export.quality", "Quality", 1.0, 1 pub(super) const LIMIT_KB: ControlSpec = spec("export.limitKb", "Limit file size (KB, 0 = off)", 0.0, 20_000.0, 0.0, 10.0); -const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 16.0); +// whole pixels: a typed size is snapped to the step, and 1080 or 1350 must stay what was typed +const SIZE_PX: ControlSpec = spec("export.sizePx", "Pixels", 16.0, 20_000.0, 2048.0, 1.0); -const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 16.0); +const SIZE_W: ControlSpec = spec("export.sizeW", "Width (px)", 16.0, 20_000.0, 2048.0, 1.0); -const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 16.0); +const SIZE_H: ControlSpec = spec("export.sizeH", "Height (px)", 16.0, 20_000.0, 2048.0, 1.0); const SIZE_MP: ControlSpec = ControlSpec { decimals: 1, ..spec("export.sizeMp", "Megapixels", 0.1, 100.0, 12.0, 0.1) }; @@ -300,3 +324,109 @@ mod watermark_position_tests { } } } + +#[cfg(test)] +mod tiff_lzw_note_tests { + use std::time::Duration; + + use lightcraft_engine::export::{ExportFormat, ExportOptions, TiffCompression}; + use serde_json::json; + + use super::{TIFF_LZW_NOTE, TIFF_LZW_NOTE_16, export_dialog_params, preset_lzw_note, tiff_lzw_note}; + use crate::headless::Headless; + use crate::state::Dialog; + use crate::{LightcraftApp, Services}; + + const T: Duration = Duration::from_secs(20); + + fn tiff(compression: TiffCompression, bit_depth: Option) -> ExportOptions { + ExportOptions { format: ExportFormat::Tiff, tiff_compression: compression, bit_depth, ..Default::default() } + } + + // Issue #2882: the note shows for LZW only, worded more strongly at 16 bit (TIFF's default depth) + #[test] + fn lzw_gets_a_note_stronger_at_16_bit() { + assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, None)), Some(TIFF_LZW_NOTE_16), "TIFF defaults to 16-bit"); + assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(16))), Some(TIFF_LZW_NOTE_16)); + assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(8))), Some(TIFF_LZW_NOTE)); + assert_eq!(tiff_lzw_note(&tiff(TiffCompression::Lzw, Some(32))), Some(TIFF_LZW_NOTE)); + for compression in [TiffCompression::None, TiffCompression::Deflate, TiffCompression::PackBits] { + for depth in [None, Some(8), Some(16), Some(32)] { + assert_eq!(tiff_lzw_note(&tiff(compression, depth)), None, "{compression:?} {depth:?}"); + } + } + // the setting only matters for TIFF: a JPEG preset that carries it gets no note + let jpeg = ExportOptions { format: ExportFormat::Jpeg, tiff_compression: TiffCompression::Lzw, ..Default::default() }; + assert_eq!(tiff_lzw_note(&jpeg), None); + assert!(TIFF_LZW_NOTE_16.contains("16 bit") && TIFF_LZW_NOTE.contains("other software needs LZW")); + } + + // Presets that write LZW TIFFs carry the same hint; the built-in ones don't write LZW + #[test] + fn presets_with_lzw_get_the_same_hint() { + assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "lzw"})), Some(TIFF_LZW_NOTE_16)); + assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "LZW", "bitDepth": 8})), Some(TIFF_LZW_NOTE)); + assert_eq!(preset_lzw_note(&json!({"format": "tiff", "tiffCompression": "zip"})), None); + assert_eq!(preset_lzw_note(&json!({"format": "tiff"})), None, "ZIP is the default"); + for p in lightcraft_engine::export::builtin_presets() { + assert_eq!(preset_lzw_note(&p.params), None, "{}", p.name); + } + } + + // The note never changes the choice: the dialog exports exactly the compression picked + #[test] + fn the_dialog_keeps_lzw_as_picked() { + for depth in [None, Some(8), Some(16), Some(32)] { + let o = tiff(TiffCompression::Lzw, depth); + let params = export_dialog_params(&o, true, &Default::default(), 0); + assert_eq!(ExportOptions::from_json(¶ms).tiff_compression, TiffCompression::Lzw, "{depth:?}: {params}"); + } + } + + fn set(h: &mut Headless, compression: TiffCompression, bit_depth: Option) { + if let Some(Dialog::Export { opts, .. }) = &mut h.app.ui.dialog { + opts.format = ExportFormat::Tiff; + opts.tiff_compression = compression; + opts.bit_depth = bit_depth; + } else { + panic!("no Export dialog"); + } + h.settle(T); + } + + fn note_rect(h: &Headless) -> Option { + h.app.widgets.iter().find(|(w, _)| w == "note:exportTiffLzw").map(|(_, r)| *r) + } + + fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) + } + + // Scenario: in the Export dialog, picking LZW for a TIFF shows the note under Compression; ZIP + // and None show none, and the choice stays LZW + #[test] + fn export_dialog_shows_the_lzw_note_under_compression() { + let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1600.0, 1000.0], 1.0); + h.settle(T); + let r = h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(T); + for compression in [TiffCompression::Deflate, TiffCompression::None] { + set(&mut h, compression, None); + assert!(note_rect(&h).is_none(), "{compression:?} has no note"); + } + for depth in [None, Some(8), Some(32)] { + set(&mut h, TiffCompression::Lzw, depth); + let note = note_rect(&h).unwrap_or_else(|| panic!("LZW at {depth:?} shows the note")); + // under the Compression choice, inside the dialog + assert!(note.top() >= widget(&h, "button:exportTiffCompression-1").bottom() - 1.0, "{note:?}"); + assert!(widget(&h, "dialog:window").contains_rect(note), "{note:?}"); + let Some(Dialog::Export { opts, .. }) = &h.app.ui.dialog else { panic!("dialog closed") }; + assert_eq!(opts.tiff_compression, TiffCompression::Lzw, "the note leaves the choice alone"); + } + set(&mut h, TiffCompression::Deflate, None); + assert!(note_rect(&h).is_none(), "back to ZIP: the note goes"); + } +} diff --git a/crates/ui-egui/src/dialogs/merge.rs b/crates/ui-egui/src/dialogs/merge.rs index 4dc744b6d..c9303b498 100644 --- a/crates/ui-egui/src/dialogs/merge.rs +++ b/crates/ui-egui/src/dialogs/merge.rs @@ -86,6 +86,8 @@ pub struct MergeTask { pub job: MergeJob, /// The dialog options it was started with. pub options: MergeDialog, + /// The photos it merges. + pub ids: Vec, pub progress: Arc>, pub cancel: Arc, rx: Receiver>, @@ -102,8 +104,9 @@ pub struct MergeState { pub preview: Option<(egui::TextureHandle, [usize; 2], MergeDialog, Value)>, pub preview_pixels: Option>, pub error: Option, - /// Options whose preview failed (not retried until they change). - pub failed_options: Option, + /// The photos and options whose preview failed (not retried until either changes or the dialog + /// opens again). + pub failed_options: Option<(Vec, MergeDialog)>, /// The preview zoomed in by a click: the point of it (0..1 across and down) at the middle of /// the box. `None` fits it in the box. Kept while the options change, to compare them there. pub zoom: Option<(f32, f32)>, @@ -146,7 +149,7 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b std::thread::Builder::new().name("photo-merge".into()).spawn(work).map_err(|e| e.to_string())?; #[cfg(target_arch = "wasm32")] work(); - Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None }) + Ok(MergeTask { preview, job, options: options.clone(), ids: ids.to_vec(), progress, cancel, rx, guard: None }) } /// The options of the last merge of kind `command` started (kept in the library preferences), or @@ -165,6 +168,8 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result { } app.merge.ids = ids; app.merge.error = None; + // a preview that failed before is tried again, or the dialog would show neither a preview nor its error (#2708) + app.merge.failed_options = None; app.merge.preview = None; app.merge.preview_pixels = None; app.merge.zoom = None; @@ -257,7 +262,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } Err(e) => { app.merge.error = Some(e); - app.merge.failed_options = Some(opts.clone()); + app.merge.failed_options = Some((ids, opts.clone())); } } } @@ -289,7 +294,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(t) = app.merge.preview_task.take() else { return }; if e != "cancelled" { app.merge.error = Some(e); - app.merge.failed_options = Some(t.options); + app.merge.failed_options = Some((t.ids, t.options)); } } Err(_) => ctx.request_repaint_after(std::time::Duration::from_millis(50)), @@ -366,7 +371,7 @@ fn added_message(what: &str, info: &Value) -> String { } fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { - app.merge.failed_options.as_ref() == Some(opts) + app.merge.failed_options.as_ref().is_some_and(|(ids, o)| ids == &app.merge.ids && o == opts) } /// Where a preview of `size` pixels is drawn in `area`: fitted, or zoomed in on the point `zoom` diff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs index 584fadd4d..79c01d24b 100644 --- a/crates/ui-egui/src/headless/tests.rs +++ b/crates/ui-egui/src/headless/tests.rs @@ -4355,13 +4355,26 @@ fn settings_dialog_by_keyboard_and_clicks() { assert_eq!(h.app.session.cache_mb, 512); h.request("ui.clickWidget", json!({"id": "button:settingsPreview-3"}), t); assert_eq!(h.app.ui.settings.preview_limit, 3840); - h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); - assert_eq!(h.app.ui.settings.memory_mb, 1024); - h.step(); - assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget"); - h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t); - h.step(); - assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic"); + { + // the memory budget is process-wide: this test's turn to change it, and it goes back + // to what it was (not the default) when the guard drops, also on a failed assertion + // (issue #3065). The change lasts these few frames. + use lightcraft_engine::memory::{budget, cache_share, default_budget}; + let _budget = crate::test_budget::BudgetGuard::hold(); + // the session's own share, set apart from the 1024 MB choice's: only the command + // changes it, whatever another test does to the process-wide budget + h.app.session.media.set_budget(cache_share(1024 << 20) + (1 << 20)); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); + assert_eq!(h.app.ui.settings.memory_mb, 1024); + h.step(); + assert_eq!(h.app.session.media.budget(), cache_share(1024 << 20), "the session's caches follow"); + assert_eq!(budget(), 1024 << 20, "applied through app.memoryBudget"); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t); + h.step(); + let automatic = (default_budget() >> 20) << 20; + assert_eq!(h.app.session.media.budget(), cache_share(automatic), "back to automatic"); + assert_eq!(budget(), automatic, "back to automatic"); + } // Import: per-camera defaults h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t); @@ -4989,3 +5002,39 @@ fn screenshot_request_is_answered_without_a_window() { let r = h.request("ui.screenshot", json!({"headless": true}), SETTLE); assert_eq!(r["result"]["width"], 640, "{r}"); } + +/// A size typed in the Export dialog is the size exported: the pixel fields kept only multiples +/// of 16, so 1080 became 1088 and 3000 became 3008 (issue #3300). +#[test] +fn export_sizes_keep_the_pixels_typed() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), t); + h.settle(SETTLE); + let type_value = |h: &mut Headless, id: &str, text: &str| { + let r = h.request("ui.clickWidget", json!({"id": id}), t); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.request("ui.text", json!({"text": text}), t); + h.request("ui.key", json!({"key": "Enter"}), t); + h.settle(SETTLE); + }; + let resize = |h: &Headless| match &h.app.ui.dialog { + Some(crate::state::Dialog::Export { resize, .. }) => *resize, + _ => panic!("the dialog closed"), + }; + if let Some(crate::state::Dialog::Export { full_size, resize, .. }) = &mut h.app.ui.dialog { + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(2048); + } + h.settle(SETTLE); + type_value(&mut h, "sliderValue:export.sizePx", "3000"); + assert_eq!(resize(&h).value, 3000.0); + if let Some(crate::state::Dialog::Export { resize, .. }) = &mut h.app.ui.dialog { + resize.mode = lightcraft_engine::export::ResizeMode::WidthHeight; + resize.height = 2048; + } + h.settle(SETTLE); + type_value(&mut h, "sliderValue:export.sizeW", "1080"); + type_value(&mut h, "sliderValue:export.sizeH", "1350"); + assert_eq!((resize(&h).value, resize(&h).height), (1080.0, 1350)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 98ca9c45f..1ec2a408c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -51,6 +51,8 @@ pub mod theme; pub mod widgets; pub mod window_place; +#[cfg(test)] +mod test_budget; #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index cbcfcb318..29579a01f 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1521,7 +1521,12 @@ fn context_menu_locate_and_export(app: &mut LightcraftApp, ui: &mut egui::Ui, id } crate::widgets::menu_button(ui, crate::i18n::tr("Export with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { - if ui.button(&p.name).clicked() { + let r = ui.button(&p.name); + let r = match crate::dialogs::preset_lzw_note(&p.params) { + Some(note) => r.on_hover_text(crate::i18n::tr(note)), + None => r, + }; + if r.clicked() { match app.run("app.export", json!({"preset": p.name, "background": true})) { Ok(r) if r.get("background").is_some() => {} Ok(r) => { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 08e11a79c..aa7516fad 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -290,10 +290,28 @@ impl Slot { } } +/// Render workers of an app built by this crate's own tests. Most of them build an app, several +/// run at once, and a test needs a render or two at a time, not the machine's six (issue #3067: +/// sixteen tests at once ran about 96 render threads on 16 cores). A test that needs more asks +/// for them with [`Renderer::with_worker_threads`]. +#[cfg(test)] +pub(crate) const TEST_WORKERS: usize = 2; + +/// Render workers an app starts with: the machine's job-pool size, at most six (none on wasm). +#[cfg(not(test))] +fn default_workers() -> usize { + if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) } +} + +/// Render workers an app starts with in this crate's tests: [`TEST_WORKERS`]. +#[cfg(test)] +fn default_workers() -> usize { + TEST_WORKERS +} + impl Default for Renderer { fn default() -> Self { - let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::::default_threads().min(6) }; - Self::with_worker_threads(threads) + Self::with_worker_threads(default_workers()) } } diff --git a/crates/ui-egui/src/render/thumbnail_tests.rs b/crates/ui-egui/src/render/thumbnail_tests.rs index ba490c97a..f198f0484 100644 --- a/crates/ui-egui/src/render/thumbnail_tests.rs +++ b/crates/ui-egui/src/render/thumbnail_tests.rs @@ -542,6 +542,25 @@ fn a_panicking_render_is_a_failure_not_a_dead_worker() { assert_eq!(app.renderer.live_workers(), workers); } +/// An app built by this crate's tests renders on two workers, not the machine's six: several +/// tests run at once, and each of them used to start six (issue #3067). +#[test] +fn a_test_app_renders_on_two_workers() { + let (mut app, _, ctx) = fixture_with(Session::new(), (*photo()).clone()); + app.renderer.offload = None; // the real job pool + let slot = Slot::Thumb(PhotoId(1)); + let job = app.session.thumb_job(PhotoId(1), 256).unwrap(); + app.renderer.request(slot, job, 30); + assert_eq!(app.renderer.live_workers(), TEST_WORKERS, "the pool starts all its workers with its first job"); + let t0 = std::time::Instant::now(); + while app.renderer.is_pending(slot) { + assert!(t0.elapsed() < std::time::Duration::from_secs(20), "the render never answered"); + std::thread::sleep(std::time::Duration::from_millis(5)); + app.renderer.poll(&ctx, &mut app.session); + } + assert!(app.renderer.textures.contains_key(&slot), "{:?}", app.renderer.failure(slot)); +} + #[test] fn raw_quick_path_frames_build_no_jobs() { // an unedited raw: the grid shows its embedded preview (held here until released) and stops diff --git a/crates/ui-egui/src/test_budget.rs b/crates/ui-egui/src/test_budget.rs new file mode 100644 index 000000000..1442cd1a0 --- /dev/null +++ b/crates/ui-egui/src/test_budget.rs @@ -0,0 +1,93 @@ +//! For tests that change the process-wide memory budget ([`lightcraft_engine::memory::budget`]). +//! +//! The budget is one number for the whole test process: the decode, export and GPU gates, every +//! new session's caches and the demo scene cache size from it, and other tests run at the same +//! time. A test that changes it (through `app.memoryBudget` or Settings ▸ Performance) holds a +//! [`BudgetGuard`] for as long as the change lasts. Tests that change the budget then run one at +//! a time, so one can't change it under another's assertion. The budget goes back to exactly what +//! it was before, also when the test fails: putting back the value found, not `default_budget()`, +//! keeps a budget set from outside (`LIGHTCRAFT_MEMORY_MB`, a test harness) (issue #3065). +//! +//! Tests that only read the budget don't take the lock; keep the change short. +//! The lock is per process, so per test binary, which is all the budget is shared by. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use lightcraft_engine::memory; + +/// Taken by every test that changes the process-wide budget. +static CHANGING_BUDGET: Mutex<()> = Mutex::new(()); + +/// Held while a test changes the process-wide memory budget: the other tests that change it wait, +/// and the budget is put back as it was found when the guard drops. +pub(crate) struct BudgetGuard { + // fields drop in order: the budget is put back before the next test may take its turn + _restore: Restore, + _turn: MutexGuard<'static, ()>, +} + +impl BudgetGuard { + /// Wait for the other tests that change the budget, then remember the budget as it is now. + pub(crate) fn hold() -> BudgetGuard { + // poisoned: a test failed holding it, and its guard put the budget back on unwind + let turn = CHANGING_BUDGET.lock().unwrap_or_else(PoisonError::into_inner); + BudgetGuard { _restore: Restore::new(), _turn: turn } + } +} + +/// Puts the process-wide budget back to what it was when made. +struct Restore(usize); + +impl Restore { + fn new() -> Restore { + Restore(memory::budget()) + } +} + +impl Drop for Restore { + fn drop(&mut self) { + // doesn't panic; the value came from `budget()`, so it's within what `set_budget` keeps + memory::set_budget(self.0); + } +} + +mod tests { + use super::*; + + // The budget is put back as it was found, not to the default, also when the test fails + // (issue #3065) + #[test] + fn the_budget_is_put_back_as_it_was_found() { + let _turn = BudgetGuard::hold(); + // a budget set from outside the test, unlike the default + let outside = if memory::budget() == 700 << 20 { 600 << 20 } else { 700 << 20 }; + memory::set_budget(outside); + + { + let _r = Restore::new(); + memory::set_budget(1024 << 20); + assert_eq!(memory::budget(), 1024 << 20); + } + assert_eq!(memory::budget(), outside, "the budget found, not the default"); + + let failed = std::panic::catch_unwind(|| { + let _r = Restore::new(); + memory::set_budget(96 << 20); + panic!("an assertion fails while the budget is changed"); + }); + assert!(failed.is_err()); + assert_eq!(memory::budget(), outside, "put back on unwind too"); + } + + // A test that fails holding the lock doesn't make the next one fail + #[test] + fn a_failed_test_does_not_block_the_next() { + let failed = std::thread::spawn(|| { + let _g = BudgetGuard::hold(); + panic!("an assertion fails while holding the turn"); + }) + .join(); + assert!(failed.is_err()); + let _g = BudgetGuard::hold(); + } +} diff --git a/crates/ui-egui/src/tests/activity.rs b/crates/ui-egui/src/tests/activity.rs index 284083ee5..b353405ff 100644 --- a/crates/ui-egui/src/tests/activity.rs +++ b/crates/ui-egui/src/tests/activity.rs @@ -443,6 +443,34 @@ fn merge_dialog_remembers_the_last_settings_per_kind() { let _ = std::fs::remove_dir_all(dir); } +/// Issue #2708: a preview that failed (a photo's file missing) is tried again when the dialog opens again; it used to +/// show neither a preview nor an error until an option changed. +#[test] +fn merge_dialog_retries_a_failed_preview_when_it_opens_again() { + let (mut h, dir) = bracket("merge-retry"); + let (file, away) = (dir.join("IMG_1.dng"), dir.join("IMG_1.away")); + std::fs::rename(&file, &away).unwrap(); + crate::merge::open(&mut h.app, "merge.panorama").unwrap(); + h.step(); + assert!(h.step_until(T, |h| h.app.merge.preview_task.is_none())); + assert!(h.app.merge.error.is_some(), "the preview failed"); + h.app.ui.dialog = None; + h.step(); + // the file is back (a drive reconnected); the same photos and options again + std::fs::rename(&away, &file).unwrap(); + crate::merge::open(&mut h.app, "merge.panorama").unwrap(); + h.step(); + assert!( + h.app.merge.preview.is_some() || h.app.merge.preview_task.is_some() || h.app.merge.error.is_some(), + "the dialog shows a preview, one being made, or why not" + ); + assert!(h.step_until(T, |h| h.app.merge.preview_task.is_none())); + assert!(h.app.merge.preview.is_some(), "{:?}", h.app.merge.error); + h.app.ui.dialog = None; + h.step(); + let _ = std::fs::remove_dir_all(dir); +} + /// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the /// rest. #[test] diff --git a/crates/ui-egui/src/tests/drop_import.rs b/crates/ui-egui/src/tests/drop_import.rs index afe695ce2..e5d427d65 100644 --- a/crates/ui-egui/src/tests/drop_import.rs +++ b/crates/ui-egui/src/tests/drop_import.rs @@ -131,7 +131,10 @@ fn app_with_library(dir: &Scratch) -> Headless { } /// Draw the launch list (`over_open_library`: Alt-at-launch over the library that opened, else the -/// list with no library behind it). +/// list with no library behind it), then wait until it holds still. Each recent library's row says +/// "Checking…" until a worker answers; the answer drops that line, so the centred window shrinks and +/// its buttons move. A click aimed before then can find its button gone from under the pointer by +/// the release, and `ui.clickWidget` refuses it. fn draw_launch_list(h: &mut Headless, over_open_library: bool) { let mut problem = crate::panels::library_problem::LibraryProblem::choose_later(&h.app.ui.settings); if over_open_library && let Some(c) = problem.chooser.as_mut() { @@ -139,6 +142,26 @@ fn draw_launch_list(h: &mut Headless, over_open_library: bool) { } h.app.library_problem = Some(problem); assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:libraryOpen")), "the launch list is drawn"); + let answered = + |h: &Headless| h.app.library_problem.as_ref().and_then(|p| p.chooser.as_ref()).is_some_and(|c| c.libraries.iter().all(|l| !l.checking)); + assert!(h.step_until(T, answered), "every recent library is checked"); + wait_until_still(h, "button:libraryOpen"); +} + +/// Run frames until widget `id` keeps its rect from one frame to the next (an anchored window +/// takes a frame to move to its new size). +fn wait_until_still(h: &mut Headless, id: &str) { + let rect = |h: &Headless| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r); + let mut last = rect(h); + for _ in 0..60 { + h.step(); + let now = rect(h); + if now.is_some() && now == last { + return; + } + last = now; + } + panic!("{id} never held still"); } /// The launch list as Alt-at-launch shows it over the library that opened. diff --git a/crates/ui-egui/tests/it/accessibility.rs b/crates/ui-egui/tests/it/accessibility.rs index 04d64f3e5..39b707cd3 100644 --- a/crates/ui-egui/tests/it/accessibility.rs +++ b/crates/ui-egui/tests/it/accessibility.rs @@ -14,7 +14,13 @@ fn harness(view: ViewMode, right: RightPanel) -> Harness<'static, LightcraftApp> app.ui.left_panel = true; app.ui.view = view; app.ui.right = right; - let mut h = Harness::builder().with_size(vec2(1500.0, 950.0)).build_ui_state( + let mut builder = Harness::builder().with_size(vec2(1500.0, 950.0)); + // text such as "▸" in the Reference view's hint has its glyph only in the craft-fonts faces (releases + // always have them): without them it is drawn as a box, which this audit of names doesn't need + if lightcraft_engine::CRAFT_FONTS.is_empty() { + builder = builder.allow_missing_glyphs(); + } + let mut h = builder.build_ui_state( |ui, app: &mut LightcraftApp| { app.logic(&ui.ctx().clone()); app.ui(ui); @@ -80,6 +86,10 @@ fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec { #[test] fn every_interactive_node_has_a_name_in_every_view_and_tool() { + // said once per audit, not once per harness + if lightcraft_engine::CRAFT_FONTS.is_empty() { + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR: glyphs only craft-fonts has are drawn as boxes, not checked"); + } let mut problems = Vec::new(); for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] { let h = harness(view, RightPanel::None); diff --git a/docs/gaps.md b/docs/gaps.md index c3f974fd2..04ee030f8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -96,6 +96,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-24 | patch | Typed GPS input now validates the complete coordinate expression, rejecting unknown text, wrong-axis or conflicting hemispheres, and invalid DMS minutes/seconds before changing metadata; ranked gaps, estimates and readiness counts unchanged | +| 2026-10-24 | patch | LR-EXP-METADATA: Original export refuses embedded GPS or nonblank place names when Remove Location Info is on; ranked gaps, estimates and readiness unchanged | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged | | 2026-10-22 | patch | Gap 28: View shows the current grid-cycle key beside Photo Grid and Square Grid (#2500); no new binding and no change to the remaining shortcut gap or hours | | 2026-10-22 | minor | Gap 15: the Print page setup and PDF gain the Japanese photo and postcard paper sizes (L判 89×127, 2L判 127×178, KG判 102×152, はがき 100×148, 往復はがき 148×200, 六切 203×254, 四切 254×305, 名刺 55×91 mm), by their customary names; gap not closed | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 47d1855d7..2965a789a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LR-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-DRAGDROP: a right-click on a text field while photos are carried from the grid opens no Cut / Copy / Paste menu; LR-BEHAV-DRAGDROP: a right-click while photos are carried from the grid opens no context menu; LR-LIB-VIRTUALCOPY: default numbering is preflighted across the requested batch and explicit names bypass suffix allocation; LR-BEHAV-DRAGDROP: only the primary button drags photos out of the grid and drops them; KEYC-RATING: deleting a keyword while Enable Filters has the filter put aside no longer leaves an empty one there; LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-LOCATION: Info GPS text is fully validated before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in point fields; malformed references error; CDATA stays literal; LR-BEHAV-ACCESS: the Thumbnail size control is a slider to screen readers; LR-BEHAV-ACCESS: the Detail photo is named with its file name; LR-VIEW-PHOTOGRID: the grid's empty-view message names its shortcut as this platform writes it and as it is bound; LR-BEHAV-ACCESS: the grid's date headers are named as they read; LR-BEHAV-ACCESS: the sidebar's disclosure triangles name their row; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K Set Keyword Shortcut and ⇧K; KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K Edit Keywords; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-FOLDERS: Local's built-in places (Pictures, Desktop, Downloads, Home) offer no Rename Folder… or Move Folder To…, and `folder.rename` / `folder.move` refuse the home folder and its Pictures, Desktop and Downloads; LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (LR-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-DRAGDROP: a right-click on a text field while photos are carried from the grid opens no Cut / Copy / Paste menu; LR-BEHAV-DRAGDROP: a right-click while photos are carried from the grid opens no context menu; LR-LIB-VIRTUALCOPY: default numbering is preflighted across the requested batch and explicit names bypass suffix allocation; LR-BEHAV-DRAGDROP: only the primary button drags photos out of the grid and drops them; KEYC-RATING: deleting a keyword while Enable Filters has the filter put aside no longer leaves an empty one there; LR-BEHAV-DRAGDROP: a drop's destination is named with its parent when another folder or album has the same name; LR-VIEW-FILMSTRIP: photos drag from the filmstrip to the same sidebar targets as from the grid; LRC-LIB-LIBFOLDERS: a deep row's count follows its name in a narrow panel; LR-FILT-FLAG / LRC-LIB-FILTERBAR: the flag toggles combine, Picked + Unflagged, #2588; LR-VIEW-PHOTOGRID, LR-VIEW-FILMSTRIP, LR-VIEW-COMPARE, LR-VIEW-SURVEY, LRC-LIB-REFVIEW, LRC-LIB-PEOPLE: the active photo's selection ring shows on light themes and in Compare, Survey and the face tiles, and needs a loaded thumbnail; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-META-LOCATION: Info GPS text is fully validated before metadata changes; LR-MAP-TRACKLOG decodes GPX character references in point fields; malformed references error; CDATA stays literal; LR-BEHAV-ACCESS: the Thumbnail size control is a slider to screen readers; LR-BEHAV-ACCESS: the Detail photo is named with its file name; LR-VIEW-PHOTOGRID: the grid's empty-view message names its shortcut as this platform writes it and as it is bound; LR-BEHAV-ACCESS: the grid's date headers are named as they read; LR-BEHAV-ACCESS: the sidebar's disclosure triangles name their row; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌘⌥⇧K Set Keyword Shortcut and ⇧K; KEYC-METADATA: ⌘⇧K / Ctrl+Shift+K Edit Keywords; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LRC-LIB-FOLDERS: Local's built-in places (Pictures, Desktop, Downloads, Home) offer no Rename Folder… or Move Folder To…, and `folder.rename` / `folder.move` refuse the home folder and its Pictures, Desktop and Downloads; LRC-LIB-CATALOG-SWITCH: a library's catalog.snap or folder given at launch opens that library; LR-IMP-ADD-DIALOG / LR-IMP-MOVE: checked rename-sequence exhaustion; LR-IMP-ADD-DIALOG: an import asks the disk about the probes a scan left, and for each photo's sidecar, up to 16 files at a time; LRC-LIB-LIBFOLDERS: Don't ask again in the Move Photos question is kept only when the move started; a refused move leaves the question on; LR-IMP-ADD-DIALOG: remembered import thumbnail size, density-aware bounded previews and one job per candidate/size bucket (#2237, part of #2056); import loupe remains open; counts unchanged; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; LR-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the dialog makes a new preview each time it opens, also after a failed one, #2708; LR-FILT-SEARCH-META: `>=` / `<=` in fielded search tokens; MENU-FILE-CLOSE, KEY-CLOSEWINDOW: the notes say Close Window quits the app, after the same check as Quit; MENU-EDIT-SELECTNONE, MENU-EDIT-PASTESELECTED: Edit lists Select Only Active Photo after Deselect All and Paste Settings from Previous after Paste Selected Settings…; on macOS no doubled separators are left where Settings… moved to the app menu; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -355,9 +355,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, click-to-zoom preview (drag pans; the zoom stays while options change), auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | -| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto (projected with Lanczos-3), boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | -| LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | +| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align (Lanczos-3 warp), deghost None–High + overlay, click-to-zoom preview (drag pans; the zoom stays while options change), auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144; the dialog makes a new preview each time it opens, also after a failed one (issue #2708, `merge_dialog_retries_a_failed_preview_when_it_opens_again`). Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto (projected with Lanczos-3), boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; the dialog makes a new preview each time it opens, also after a failed one (issue #2708, `merge_dialog_retries_a_failed_preview_when_it_opens_again`); no lens model / 360° wrap | +| LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF; the dialog makes a new preview each time it opens, also after a failed one (issue #2708, `merge_dialog_retries_a_failed_preview_when_it_opens_again`) | | LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | ## P. Enhance (ENH) @@ -397,11 +397,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `fit`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/dialogs.rs` | full size = the cropped native size (no longer upscaled); both of Lightroom's boxes: Dimensions (W × H fits either orientation) and Width & Height (`fit: widthHeight`, each edge bound to its own number, #1620); ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373); ordinary and HDR gain map JPEGs search quality up to the requested maximum to fit `limitKb`, including gain map and metadata, or return an error. Tested by `size_limit_respected` and `hdr_jpeg_size_limit_respected`. | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | -| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | +| LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | ZIP is the default. LZW is usually larger and slower than ZIP, and at 16 bit often larger than none (#2882); the export dialog notes this when LZW is picked | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | -| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | +| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | Original export is refused when Remove Location Info is on and the source embeds GPS or a nonblank sublocation, city, state or country; rendered exports and XMP sidecars remove those modeled fields | | LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | @@ -823,6 +823,10 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged | | 2026-10-24 | patch | Integration batch 43: Integrated main 98702b5c, preserving the new fail-closed GPS metadata check for Original export. Watermark geometry/orientation and HDR JPEG size-limit fixes remain in their shared helpers; no feature-count changes. | | 2026-10-23 | minor | LR-FILT-FLAG, LRC-LIB-FILTERBAR: the filter bar's flag toggles combine (any of them, `library.filter` `flags`), issue #2588; status and counts unchanged | +| 2026-10-24 | patch | LR-EXP-METADATA: Original export refuses embedded GPS or nonblank place names when Remove Location Info is on; status and feature counts unchanged | +| 2026-10-24 | patch | LR-LIB-RATING, LR-LIB-DELETE: a slider drag still open when a rating, flag or label takes the photo out of the view, or when the photo is deleted, ends as its own undo step before the selection moves on, as moving to the next photo does; LR-BEHAV-LOCALIZE lists all 18 languages; status unchanged | +| 2026-10-24 | patch | Integration batch 43: Integrated main 98702b5c, preserving the new fail-closed GPS metadata check for Original export. Watermark geometry/orientation and HDR JPEG size-limit fixes remain in their shared helpers; no feature-count changes. | +| 2026-10-23 | patch | LR-MERGE-HDR / LR-MERGE-PANO / LR-MERGE-HDRPANO: the merge dialog makes a new preview each time it opens, also after a failed one (#2708); status unchanged | | 2026-10-23 | patch | Export integration: Integrated main 5ca49b3e, retaining Width & Height resizing, export format/bit-depth validation and batch panic cancellation alongside the existing watermark and HDR JPEG size-limit fixes. No feature-count changes. | | 2026-10-23 | minor | LRC-LIB-CATALOG-SWITCH, LR-PREF-GENERAL: Settings ▸ General ▸ At launch (Open the last library / Ask me) with a launch window listing the recent libraries, Alt/Option at launch asks once, File ▸ Open Recent Library ▸ Remove from List and `app.recentLibraries {remove}` (issue #1290); photos dropped while no library is open wait for the one opened (presets are refused then); status and counts unchanged (✅) | | 2026-10-23 | minor | LRC-LIB-CATALOG-IMPORT: Import Lightroom Catalog… (and Open Library's offer for a Lightroom catalog) asks whether the catalog merges into the open library or goes into a new library made next to it; Settings ▸ General or "Don't ask again" switches the question off; `file.importLightroom` takes `into` / `name` / `location` (#2672); status unchanged (🟡) | diff --git a/docs/user/guides/export-photos.md b/docs/user/guides/export-photos.md index 71bca3241..985263644 100644 --- a/docs/user/guides/export-photos.md +++ b/docs/user/guides/export-photos.md @@ -16,11 +16,12 @@ Select the photos, then choose **File** > **Export…**. Pick a format, a size a 5. Under the size options, tick **Full size**, or clear it and choose **Resize to** one of **Long Edge**, **Short Edge**, **Width**, **Height**, **Width × Height**, **Megapixels** or **Percentage**, then enter the value. 6. Set **Color space** (**sRGB**, **P3**, **Adobe RGB**, **ProPhoto** or **Rec.2020**) if you need something other than the default. Lossy **JPEG XL** writes **Adobe RGB** and **ProPhoto** as **Rec.2020**. Tick **Lossless** to keep them. 7. Optional: set **Sharpen** for **Screen**, **Matte** or **Glossy** output, set **Metadata** to **All**, **No camera**, **Copyright** or **None**, and tick **Remove location info** or **Watermark**. -8. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it. -9. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert. -10. Set **If file exists** to **Add number**, **Overwrite** or **Skip**. -11. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos. -12. Click **OK**. The export shows its progress in the activity stack at the top left. +8. For **TIFF**, set **Compression** to **ZIP** (the default), **LZW** or **None**. **LZW** is usually larger and slower than **ZIP**, and at 16 bit it can be larger than **None**. Use it only if other software needs LZW. +9. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it. +10. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert. +11. Set **If file exists** to **Add number**, **Overwrite** or **Skip**. +12. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos. +13. Click **OK**. The export shows its progress in the activity stack at the top left. If you did not choose a folder, the window stays open and shows an error, so your choices are not lost. diff --git a/docs/whats-new.md b/docs/whats-new.md index 430e638b5..f15b48cbb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -21,6 +21,10 @@ or a Synchronize Folder is still running then, they now wait for it to end instead of being dropped, and a message says so (issue #2779). +### Export sizes keep the pixels typed +- Export ▸ Image Sizing kept pixel sizes only in steps of 16, so a typed 1080 became 1088 and 3000 became 3008. The + long edge, short edge, width and height fields now keep any whole number of pixels. + ## 2026-10-29 ### A person's page opens in seconds in a large library @@ -135,6 +139,9 @@ - Looking for each photo's `.xmp` sidecar, which asks the disk several names for every photo, also goes up to 16 photos at a time. +### Original export checks embedded place names when removing location +- Original copies are refused when “Remove Location Info” is enabled and the source contains GPS or a nonblank sublocation, city, state or country. Use JPEG or TIFF for rendered output; DNG is available for raw photos. + ### Relative-date filters and smart albums follow the clock - A current view or source count using “in the last” rules now refreshes when the session clock moves into a new @@ -194,6 +201,11 @@ early, or a GIF whose first frame is smaller than the picture) still opens up to 4 megapixels; a larger one is refused, because opening it would still set aside memory for the whole picture. +### Merge previews after a failed one +- The HDR, Panorama and HDR Panorama dialogs make a new preview each time they open (issue #2708). After a preview + had failed once, for example because a photo's file was missing, opening the dialog again with the same options + showed neither a preview nor an error until an option was changed. + ### Tab leaves the name field in New Album, New Smart Album and the name prompts - In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the