diff --git a/AGENTS.md b/AGENTS.md index 297682755..c9f54b71d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,11 +125,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md` with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. - Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`. +- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")` + before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and + data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR). ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers +(dev-dependency only). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start. diff --git a/CLAUDE.md b/CLAUDE.md index 5b4702cdb..00a2314aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,11 +133,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md` with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. - Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`. +- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")` + before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and + data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR). ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers +(dev-dependency only). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start. diff --git a/Cargo.lock b/Cargo.lock index db6efd50e..db4e6aa6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3081,6 +3081,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-raster", "lightcraft-sysmem", + "lightcraft-testkit", "lightcraft-ui-egui", "log", "muda", @@ -3144,6 +3145,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-heif", "lightcraft-raster", + "lightcraft-testkit", "moxcms 0.9.1", "png", "proptest", @@ -3240,6 +3242,7 @@ dependencies = [ "lightcraft-scenes", "lightcraft-segment", "lightcraft-slideshow", + "lightcraft-testkit", "lightcraft-tiff", "log", "miniz_oxide 0.8.9", @@ -3255,6 +3258,7 @@ dependencies = [ name = "lightcraft-faces" version = "0.8.0" dependencies = [ + "lightcraft-testkit", "matrixmultiply", "ndarray", "proptest", @@ -3305,6 +3309,7 @@ dependencies = [ "lightcraft-raster", "lightcraft-raw", "lightcraft-scenes", + "lightcraft-testkit", "log", "pollster 1.0.1", "wgpu", @@ -3419,12 +3424,14 @@ dependencies = [ name = "lightcraft-raw" version = "0.8.0" dependencies = [ + "dhat", "jpeg-encoder", "jxl-oxide", "lightcraft-color", "lightcraft-geom", "lightcraft-meta", "lightcraft-raster", + "lightcraft-testkit", "lightcraft-tiff", "miniz_oxide 0.8.9", "proptest", @@ -3455,6 +3462,7 @@ dependencies = [ "candle-core", "candle-nn", "lightcraft-fetch", + "lightcraft-testkit", "log", "serde", "serde_json", @@ -3474,6 +3482,10 @@ dependencies = [ name = "lightcraft-sysmem" version = "0.8.0" +[[package]] +name = "lightcraft-testkit" +version = "0.8.0" + [[package]] name = "lightcraft-tiff" version = "0.8.0" @@ -3504,6 +3516,7 @@ dependencies = [ "lightcraft-preview", "lightcraft-raster", "lightcraft-slideshow", + "lightcraft-testkit", "log", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index de985d654..ac8cbeca1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } lightcraft-geocode = { path = "crates/geocode" } lightcraft-slideshow = { path = "crates/slideshow" } +lightcraft-testkit = { path = "crates/testkit" } # foundation thiserror = "2" @@ -53,7 +54,7 @@ bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" siphasher = "1" libc = "0.2" # dev-only: process CPU time in benches -dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds +dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds # formats quick-xml = "0.38" diff --git a/README.md b/README.md index 583e8f687..ff9fbee55 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,20 @@ lightcraft --control 7980 ~/Pictures/trip with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle. This is a contact-sheet workflow, not a printer-driver or custom picture-package layout editor. +- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image, + Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells, + stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper + sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi, + and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command + (`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`, + `print.template.save`…), so the CLI and MCP drive it too: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \ + print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf + ``` + + Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet. - **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a side door. - The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts. @@ -395,6 +409,13 @@ 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. +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 +adapters per backend, corpus, fonts) and every skip by reason. It writes `target/ci/report.json` and +`target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the +GPU tests on every backend that has a hardware adapter. Nothing is uploaded. + **Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it): ```sh diff --git a/ROADMAP.md b/ROADMAP.md index a308a7f4f..d642c7af9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -12,7 +12,7 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are | | Value | Kind | |---|---:|---| -| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | +| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | | **Feature breadth** (weighted by use) | **~77%** | estimated | | **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions | | **Mainstream practitioner** | **~51%** (46–56%) | estimated | @@ -45,7 +45,7 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~60% | 82% of 1,446 Adobe models decode, 36% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | | UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | @@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see | Area | Ready | Breadth | Remaining | |---|---:|---:|---:| | Import and library | 80% | 90% | 25–45 h | -| RAW decoding and camera coverage | 77% | 81% | 120–220 h | +| RAW decoding and camera coverage | 77% | 82% | 120–220 h | | Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h | | Global develop adjustments | 70% | 93% | 30–60 h | | Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h | @@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc | Korean | ko | 0% | none (no Hangul face) | | Vietnamese | vi | 0% | none | -Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%). +Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%). ## Upcoming @@ -114,6 +114,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor + hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking + identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277. - 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of photographers on a verified body; headline numbers unchanged. @@ -180,6 +183,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | | 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged | | 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | | 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged | @@ -188,6 +192,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged | | 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | +| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged | | 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% | | 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 04bf7a145..01dae3055 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -828,6 +828,8 @@ fn snapshot(args: &[String]) -> Result<(), String> { // before the first frame can start background adapter discovery. // This UI state is session-local: the CLI neither loads nor saves desktop ui.json. app.ui.settings.gpu = false; + // as the desktop app opens it: an empty library on the grid, where Import is offered + lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app); let mut h = Headless::new(app, size, scale); let timeout = Duration::from_secs(60); let mut shots = 0usize; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 8782fb80f..8f56bafe6 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -47,6 +47,48 @@ fn contact_sheet_exports_pdf_and_protects_originals() { assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output"); } +#[test] +fn print_commands_chain_into_a_picture_package_pdf() { + let input = tmp("print-input.png"); + gradient_png(&input); + let out = tmp("print.pdf"); + let (ok, lines, err) = run_cli( + &[ + "--import", + input.to_str().unwrap(), + "print.set", + r#"layout={"style":"picturePackage","page":{"paper":"4x6","orientation":"landscape"}}"#, + r#"image={"zoomToFill":true,"borderEnabled":true,"borderWidth":0.05}"#, + "job={\"ppi\":72}", + "print.cell.add", + "width=1.5", + "height=1", + "print.cell.add", + "width=1.5", + "height=1", + "print.layout.autoLayout", + "print.exportPdf", + &format!("path={}", out.display()), + ], + None, + ); + assert!(ok, "{err}: {lines:?}"); + let export = lines.last().unwrap(); + assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1))); + let bytes = std::fs::read(&out).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + let text = String::from_utf8_lossy(&bytes); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in"); + // a bad setting fails the run and writes nothing new + let before = std::fs::read(&out).unwrap(); + let (ok, _, _) = + run_cli(&["--import", input.to_str().unwrap(), "print.set", "job={\"ppi\":5}", "print.exportPdf", &format!("path={}", out.display())], None); + assert!(!ok); + assert_eq!(std::fs::read(&out).unwrap(), before); +} + fn tmp(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index bbe495c7d..26122c3e6 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -48,3 +48,7 @@ muda = { workspace = true } objc2 = { workspace = true } objc2-app-kit = { workspace = true } objc2-foundation = { workspace = true } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index d6033f678..95739b3e9 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -978,6 +978,11 @@ fn main() -> eframe::Result { app.notices.extend(launch_notices); app.notices.extend(window_notice); app.gpu_notice = true; + // an empty library opens where Import is offered (a grid, with the sidebar); before + // the snapshot below, so this alone isn't written to the settings file + if problem.is_none() && files.is_empty() { + lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app); + } // what's on disk now: only changes are written let writer = PrefsWriter { path: if in_memory { None } else { prefs_path() }, diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 66e5a78d7..1f84ec477 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -278,11 +278,11 @@ mod tests { ..wgpu::InstanceDescriptor::new_without_display_handle() }); let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { - eprintln!("no GPU adapter: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; }; let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { - eprintln!("no GPU device: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU device"); return; }; install_error_handler(&device); diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index ff371b3ca..e777b658c 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -32,6 +32,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | | `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending | +| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are diff --git a/crates/catalog/proptest-regressions/tests_index.txt b/crates/catalog/proptest-regressions/tests_index.txt new file mode 100644 index 000000000..9c827f0c0 --- /dev/null +++ b/crates/catalog/proptest-regressions/tests_index.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)] +cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)] diff --git a/crates/catalog/src/index.rs b/crates/catalog/src/index.rs new file mode 100644 index 000000000..2c347473b --- /dev/null +++ b/crates/catalog/src/index.rs @@ -0,0 +1,101 @@ +//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library +//! don't scan a list each time: which stack a photo is in, whether a regular album holds it. +//! +//! They are never saved (the on-disk format is unchanged). Each is built on first use from what +//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts +//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to +//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`, +//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the +//! rollback of a failed batch included, as those are ops too. + +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use crate::{AlbumId, Catalog, PhotoId, StackId}; + +#[derive(Default)] +pub(crate) struct Indexes { + /// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks + /// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts. + stacks: OnceLock>>, + /// Album → its photos (regular albums; a smart album's list is empty). + albums: OnceLock>>, +} + +/// A copy builds its own when asked (copies are made to be saved, not queried). +impl Clone for Indexes { + fn clone(&self) -> Self { + Indexes::default() + } +} + +/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was +/// built. +impl PartialEq for Indexes { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for Indexes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len)); + f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish() + } +} + +impl Indexes { + /// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone). + pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) { + let Some(Some(m)) = self.stacks.get_mut() else { return }; + for p in old { + if m.get(p) == Some(&id) { + m.remove(p); + } + } + for p in new { + m.insert(*p, id); + } + } + + /// Album `id` holds `photos` now (`None`: it is gone). + pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) { + let Some(m) = self.albums.get_mut() else { return }; + match photos { + Some(photos) => { + m.insert(id, photos.iter().copied().collect()); + } + None => { + m.remove(&id); + } + } + } +} + +impl Catalog { + /// The stack photo `photo` is in, by the index (built on first use). + pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option { + let m = self.index.stacks.get_or_init(|| { + let mut m = HashMap::new(); + for s in self.stacks.values() { + for p in &s.photos { + if m.insert(*p, s.id).is_some_and(|other| other != s.id) { + return None; + } + } + } + Some(m) + }); + match m { + Some(m) => m.get(&photo).copied(), + // in id order, the first stack listing the photo has it + None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id), + } + } + + /// Whether album `album`'s own list holds `photo`, by the index (built on first use). + pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool { + let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect()); + m.get(&album).is_some_and(|s| s.contains(&photo)) + } +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index df2713657..1776c895c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -15,6 +15,7 @@ pub mod dates; pub mod folders; +mod index; pub mod journal; pub mod keywords; pub mod local; @@ -289,6 +290,9 @@ pub struct Catalog { /// Which photos the latest ops touched ([`Catalog::touched_since`]). #[serde(skip)] changes: Changes, + /// Lookups derived from the above (never saved), see [`index`]. + #[serde(skip)] + index: index::Indexes, } /// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a @@ -435,13 +439,18 @@ impl Catalog { /// no smart album). pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { match self.albums.get(&id) { - // guarded: a smart album testing smart albums can't loop or recurse without end - Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)), - Some(a) => a.photos.contains(&p.id), + Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p), + Some(a) => self.album_lists(a.id, p.id), None => false, } } + /// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`. + fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool { + // guarded: a smart album testing smart albums can't loop or recurse without end + !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared)) + } + /// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the /// rules of `from` would then change when those of `to` do). Loops in saved rules end the /// search, they don't repeat it. @@ -489,7 +498,10 @@ impl Catalog { /// The photos of an album: the stored list, or a smart album's current matches (id order). pub fn album_photos(&self, id: AlbumId) -> Vec { match self.albums.get(&id) { - Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(), + Some(Album { smart: Some(rules), .. }) => { + let prepared = rules.prepare(); + self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect() + } Some(a) => a.photos.clone(), None => Vec::new(), } @@ -499,7 +511,10 @@ impl Catalog { pub fn album_count(&self, id: AlbumId) -> usize { match self.albums.get(&id) { // counted in place: no id list is built just for its length - Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(), + Some(Album { smart: Some(rules), .. }) => { + let prepared = rules.prepare(); + self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count() + } Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(), None => 0, } @@ -750,6 +765,7 @@ impl Catalog { } let id = album.id; self.next_album = self.next_album.max(id.0.saturating_add(1)); + self.index.album_changed(id, Some(&album.photos)); self.albums.insert(id, album); Op::RemoveAlbum { id } } @@ -758,6 +774,7 @@ impl Catalog { return Err(CatalogError::Invalid("folder is not empty".into())); } let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?; + self.index.album_changed(id, None); Op::AddAlbum { album: a } } Op::RenameAlbum { id, name } => { @@ -797,13 +814,16 @@ impl Catalog { Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) } } Op::SetAlbumPhotos { id, photos } => { - let a = self.album_mut(id)?; + // the field, not `album_mut`: the index is updated while the album is borrowed + let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?; if (a.folder || a.smart.is_some()) && !photos.is_empty() { return Err(CatalogError::Invalid( if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(), )); } - Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) } + let old = std::mem::replace(&mut a.photos, photos); + self.index.album_changed(id, Some(&a.photos)); + Op::SetAlbumPhotos { id, photos: old } } Op::SetAlbumCover { id, cover } => { let a = self.album_mut(id)?; @@ -824,11 +844,13 @@ impl Catalog { self.validate_stack(stack.id, &stack.photos)?; let id = stack.id; self.next_stack = self.next_stack.max(id.0.saturating_add(1)); + self.index.stack_changed(id, &[], &stack.photos); self.stacks.insert(id, stack); Op::RemoveStack { id } } Op::RemoveStack { id } => { let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?; + self.index.stack_changed(id, &s.photos, &[]); Op::AddStack { stack: s } } Op::SetStack { id, photos, collapsed } => { @@ -837,7 +859,9 @@ impl Catalog { } self.validate_stack(id, &photos)?; let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?; - let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed }; + let old_photos = std::mem::replace(&mut s.photos, photos); + self.index.stack_changed(id, &old_photos, &s.photos); + let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed }; s.collapsed = collapsed; old } @@ -1075,6 +1099,10 @@ impl Catalog { self.next_album = self.next_album.max(album); self.next_stack = self.next_stack.max(stack); } + if !changed.is_empty() { + // stacks and albums changed outside `apply`: the lookups are built again when asked + self.index = index::Indexes::default(); + } changed } @@ -1124,6 +1152,8 @@ mod tests_folders; #[cfg(test)] mod tests_format_version; #[cfg(test)] +mod tests_index; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_local; @@ -1132,4 +1162,6 @@ mod tests_lock; #[cfg(test)] mod tests_repair; #[cfg(test)] +mod tests_scale; +#[cfg(test)] mod tests_torn_append; diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 883319b32..d1548f8bd 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -135,6 +135,12 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 { mix64(mix64(seed) ^ id.0) } +/// What [`Filter::prepare`] works out once for a pass over many photos. +pub(crate) struct Prepared { + root: Option, + only: Option>, +} + fn token_matches(p: &Photo, tok: &str) -> bool { let t = tok.to_lowercase(); if let Some((field, val)) = t.split_once(':') { @@ -257,7 +263,18 @@ impl Filter { } pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { - self.matches_in(p, cat, self.library_root().as_deref()) + self.matches_in(p, cat, &self.prepare_one()) + } + + /// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed). + pub(crate) fn prepare_one(&self) -> Prepared { + Prepared { root: self.library_root(), only: None } + } + + /// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than + /// once per photo: the library folder's identity and the `only` ids as a set. + pub(crate) fn prepare(&self) -> Prepared { + Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) } } /// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice. @@ -267,9 +284,9 @@ impl Filter { self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key) } - /// [`Filter::matches`] with the library folder already turned into its identity, so a query - /// over a big library does that once, not once per photo. - fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool { + /// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a + /// big library does that once, not once per photo. + pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool { if p.deleted != self.deleted { return false; } @@ -308,7 +325,7 @@ impl Filter { if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) { return false; } - if !self.only.is_empty() && !self.only.contains(&p.id) { + if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) { return false; } if let Some(want) = &self.merged { @@ -348,7 +365,7 @@ impl Filter { { return false; } - if let Some(root) = root + if let Some(root) = prepared.root.as_deref() && !photo_in_root(p, root) { return false; @@ -398,12 +415,13 @@ impl Catalog { /// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library, /// the album, the folder; not deleted). pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet, within: &Filter) -> Vec { - let root = filter.library_root(); - let within_root = within.library_root(); + let prepared = filter.prepare(); + // `within` only decides for the few kept photos: worked out per photo, not hashed + let within_prepared = within.prepare_one(); let mut v: Vec<&Photo> = self .photos() .map(|p| p.as_ref()) - .filter(|p| filter.matches_in(p, self, root.as_deref()) || (keep.contains(&p.id) && within.matches_in(p, self, within_root.as_deref()))) + .filter(|p| filter.matches_in(p, self, &prepared) || (keep.contains(&p.id) && within.matches_in(p, self, &within_prepared))) .collect(); v.sort_by(|a, b| sort.order(a, b)); v.into_iter().map(|p| p.id).collect() @@ -417,11 +435,11 @@ impl Catalog { pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec { let listed: std::collections::HashSet = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect(); let mut ids: Vec = was.iter().copied().filter(|id| !listed.contains(id)).collect(); - let (root, within_root) = (filter.library_root(), within.and_then(Filter::library_root)); + let (prepared, within_prepared) = (filter.prepare_one(), within.map(Filter::prepare_one)); for id in touched { let Some(p) = self.photo(*id) else { continue }; - let kept = within.is_some_and(|w| listed.contains(id) && w.matches_in(p, self, within_root.as_deref())); - if filter.matches_in(p, self, root.as_deref()) || kept { + let kept = within.zip(within_prepared.as_ref()).is_some_and(|(w, wp)| listed.contains(id) && w.matches_in(p, self, wp)); + if filter.matches_in(p, self, &prepared) || kept { // (the list is in order without the touched photos, and stays so as each is put back) let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt())); ids.insert(at, *id); @@ -432,8 +450,8 @@ impl Catalog { /// How many photos match `filter`: [`Catalog::query`]'s length without its sorting. pub fn count(&self, filter: &Filter) -> usize { - let root = filter.library_root(); - self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())).count() + let prepared = filter.prepare(); + self.photos().filter(|p| filter.matches_in(p, self, &prepared)).count() } /// Year → month → day counts for the "By Date" section (newest first). @@ -489,8 +507,8 @@ impl Catalog { pub fn people_in(&self, filter: &Filter) -> Vec { let filter = Filter { person: None, ..filter.clone() }; let mut m: std::collections::HashMap = Default::default(); - let root = filter.library_root(); - for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) { + let prepared = filter.prepare(); + for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) { let mut seen: Vec = Vec::new(); for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index f17f12e01..a87c9effc 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -598,6 +598,35 @@ pub fn now() -> String { } } +/// A flag as rules name it (its variant, lower case), without building a string per photo. +fn flag_name(f: crate::Flag) -> &'static str { + match f { + crate::Flag::None => "none", + crate::Flag::Pick => "pick", + crate::Flag::Reject => "reject", + } +} + +/// A colour label's colour as rules name it (lower case). +fn label_colour(l: crate::ColorLabel) -> &'static str { + match l { + crate::ColorLabel::Red => "red", + crate::ColorLabel::Yellow => "yellow", + crate::ColorLabel::Green => "green", + crate::ColorLabel::Blue => "blue", + crate::ColorLabel::Purple => "purple", + } +} + +/// A media kind as rules name it (lower case). +fn kind_name(k: crate::MediaKind) -> &'static str { + match k { + crate::MediaKind::Image => "image", + crate::MediaKind::Raw => "raw", + crate::MediaKind::Video => "video", + } +} + fn lower(v: &Value) -> String { match v { Value::String(s) => s.trim().to_lowercase(), @@ -778,16 +807,16 @@ impl Rule { match field { "rating" => num_op(op, Some(p.rating as f64), value), "flag" => { - let f = format!("{:?}", p.flag).to_lowercase(); + let f = flag_name(p.flag); (f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is") } "label" => { - let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into()); + let l = p.label.map_or("none", label_colour); // custom label names count too - let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want); + let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want)); (l == want || named) == (op == "is") } - "kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"), + "kind" => (kind_name(p.kind) == want) == (op == "is"), "edited" => yes(p.is_edited()), "hasGps" => yes(m.gps.is_some()), "virtualCopy" => yes(p.copy_of.is_some()), diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index 1c6063256..a713b7736 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -21,7 +21,7 @@ impl Catalog { } /// The stack a photo belongs to. pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> { - self.stacks.values().find(|s| s.photos.contains(&photo)) + self.stack_id_of(photo).and_then(|id| self.stacks.get(&id)) } /// Photo → (stack, position in the stack), for every stacked photo. pub fn stack_index(&self) -> HashMap { @@ -41,7 +41,7 @@ impl Catalog { return Err(CatalogError::NoPhoto(*p)); } } - if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) { + if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) { return Err(CatalogError::Invalid("a photo can be in only one stack".into())); } Ok(()) diff --git a/crates/catalog/src/tests_index.rs b/crates/catalog/src/tests_index.rs new file mode 100644 index 000000000..f1f41501b --- /dev/null +++ b/crates/catalog/src/tests_index.rs @@ -0,0 +1,211 @@ +//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op, +//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same +//! results as before. + +use proptest::prelude::*; + +use super::*; + +const PHOTOS: u64 = 8; + +fn library() -> Catalog { + let mut c = Catalog::new(); + for i in 0..PHOTOS { + let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + c +} + +/// What the indexes must say, worked out by scanning (as the catalog did before it had them). +fn scanned(c: &Catalog) -> (Vec>, Vec>) { + let photos: Vec = (1..=PHOTOS + 1).map(PhotoId).collect(); + let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect(); + let albums = (1..=4) + .map(|a| { + photos + .iter() + .map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p))) + .collect() + }) + .collect(); + (stacks, albums) +} + +/// What the indexes say. +fn indexed(c: &Catalog) -> (Vec>, Vec>) { + let photos: Vec = (1..=PHOTOS + 1).map(PhotoId).collect(); + let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect(); + let albums = (1..=4) + .map(|a| { + photos + .iter() + .map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph))) + .collect() + }) + .collect(); + (stacks, albums) +} + +/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail). +fn ids(bits: u16) -> Vec { + (0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect() +} + +fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op { + match kind % 8 { + 0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } }, + 1 => Op::RemoveStack { id: StackId(n % 4 + 1) }, + 2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed }, + 3 => { + let mut a = Album::new(AlbumId(n % 4 + 1), "A"); + a.photos = ids(bits); + Op::AddAlbum { album: a } + } + 4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) }, + 5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) }, + // a batch whose last op fails half the time: what it did is rolled back + 6 => Op::Batch { + ops: vec![ + Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) }, + Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed }, + Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } }, + ], + }, + // as the engine deletes a photo: out of its albums and stacks too, in one batch + _ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)), + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::()), 1..40)) { + let mut c = library(); + let start = c.to_snapshot(); + // built before the ops, so every op has to keep them up to date + prop_assert_eq!(indexed(&c), scanned(&c)); + let mut log = String::new(); + let mut inverses = Vec::new(); + for (kind, n, bits, collapsed) in steps { + let op = op(&c, kind, n, bits, collapsed); + if let Ok(inv) = c.apply(op.clone()) { + log.push_str(&Catalog::op_to_log_line(&op)); + inverses.push(inv); + } + prop_assert_eq!(indexed(&c), scanned(&c)); + } + // a snapshot, a replayed journal and a clone build their own + let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + prop_assert_eq!(indexed(&snap), scanned(&c)); + let mut replayed = Catalog::from_snapshot(&start).unwrap(); + prop_assert_eq!(indexed(&replayed), scanned(&replayed)); + replayed.replay(&log).unwrap(); + prop_assert_eq!(indexed(&replayed), scanned(&c)); + prop_assert_eq!(indexed(&c.clone()), scanned(&c)); + prop_assert!(c.clone() == c, "the indexes don't take part in equality"); + // undone step by step + for inv in inverses.into_iter().rev() { + c.apply(inv).unwrap(); + prop_assert_eq!(indexed(&c), scanned(&c)); + } + prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone"); + } +} + +#[test] +fn a_photo_in_another_stack_is_refused_by_the_index() { + let mut c = library(); + c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap(); + let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err(); + assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into())); + // its own stack may keep it + c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap(); + assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1))); +} + +/// The sort keys are worked out once per photo now; the order is the one the comparisons gave. +#[test] +fn sorted_queries_keep_their_order() { + let mut c = Catalog::new(); + let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"]; + for (i, n) in names.iter().enumerate() { + let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3)); + p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4)); + p.rating = (i % 3) as u8; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let all = Filter::default(); + for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random] + { + for ascending in [true, false] { + let sort = Sort { key, ascending, seed: 7, ..Default::default() }; + // the comparison the query sorts by + let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect(); + want.sort_by(|a, b| sort.order(a, b)); + let want: Vec = want.iter().map(|p| p.id).collect(); + assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}"); + } + } + // `only` as a set gives the photos it names + let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() }; + assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]); +} + +/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave). +#[test] +fn rule_names_are_the_variants_in_lower_case() { + let mut c = Catalog::new(); + let mut n = 0; + for flag in [Flag::None, Flag::Pick, Flag::Reject] { + for label in + [None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)] + { + for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] { + n += 1; + let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00"); + (p.flag, p.label, p.kind) = (flag, label, kind); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + } + } + c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap(); + let count = |field: &str, value: String| { + let rules: RuleSet = + serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap(); + c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len() + }; + for flag in [Flag::None, Flag::Pick, Flag::Reject] { + assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}"); + } + assert_eq!(count("flag", "picked".into()), 18); + for label in ColorLabel::ALL { + assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}"); + } + assert_eq!(count("label", "sky".into()), 9, "a custom name"); + assert_eq!(count("label", "none".into()), 9); + for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] { + assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}"); + } +} + +/// A hand-edited file can put a photo in two stacks (ops refuse it): loading repairs it (the photo +/// stays in the first) and the stack index gives the answers a scan of the stacks gives, before +/// and after either stack goes. +#[test] +fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() { + let mut c = library(); + c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap(); + c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap(); + v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]); + let mut c = Catalog::from_snapshot(&v.to_string()).unwrap(); + assert_eq!(indexed(&c), scanned(&c)); + assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1))); + // the second stack can't be changed to keep it, as before + assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err()); + c.apply(Op::RemoveStack { id: StackId(1) }).unwrap(); + assert_eq!(indexed(&c), scanned(&c)); + assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), None, "the repair took it out of the second stack"); +} diff --git a/crates/catalog/src/tests_scale.rs b/crates/catalog/src/tests_scale.rs new file mode 100644 index 000000000..81ddccc41 --- /dev/null +++ b/crates/catalog/src/tests_scale.rs @@ -0,0 +1,92 @@ +//! What library queries cost at 100k photos (ignored: +//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`). + +use std::time::Instant; + +use serde_json::json; + +use super::*; + +/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags. +fn library(n: u64) -> (Catalog, AlbumId) { + let mut c = Catalog::new(); + let mut ops: Vec = (0..n) + .map(|i| { + let mut p = Photo::new( + PhotoId(i + 1), + Source::Demo { scene: (i % 20) as u32 }, + &format!("IMG_{:06}.jpg", mix64(i) % 1_000_000), + "JPEG", + 6000, + 4000, + "2026-01-01T00:00:00", + ); + p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24)); + p.rating = (i % 6) as u8; + p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None }; + p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()]; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect(); + ops.extend( + (0..n / 10) + .map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }), + ); + let album = AlbumId(1); + let mut a = Album::new(album, "Big"); + a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect(); + ops.push(Op::AddAlbum { album: a }); + let t = Instant::now(); + c.apply(Op::Batch { ops }).unwrap(); + eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3); + (c, album) +} + +fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId { + let mut a = Album::new(AlbumId(id), format!("Smart {id}")); + a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() })); + c.apply(Op::AddAlbum { album: a }).unwrap(); + AlbumId(id) +} + +/// Best of five runs (the machine may be busy with other work). +fn time(what: &str, f: impl Fn() -> T) -> T { + let mut best = f64::MAX; + let mut r = None; + for _ in 0..5 { + let t = Instant::now(); + r = Some(f()); + best = best.min(t.elapsed().as_secs_f64() * 1e3); + } + eprintln!("{what}: {best:.1} ms"); + r.unwrap() +} + +#[test] +#[ignore] +fn scale_100k() { + let (mut c, album) = library(100_000); + let all = Filter::default(); + time("query, capture date", || c.query(&all, &Sort::default())); + time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() })); + time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default())); + time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default())); + let only: Vec = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect(); + time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default())); + let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]})); + let flagged = smart( + &mut c, + 11, + json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}), + ); + let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]})); + let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]})); + let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]})); + for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] { + let n = time(&format!("smart album count, {what}"), || c.album_count(a)); + eprintln!(" = {n}"); + } + time("albums_of", || c.albums_of(PhotoId(6))); + time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default()))); + time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count()); +} diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 166fbf4dc..1ae016bc8 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -48,6 +48,8 @@ heif = ["dep:lightcraft-heif"] avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # JPEG XL tests read encoded samples without our colour conversion jxl-oxide = { workspace = true } proptest = { workspace = true } diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 33ab32efb..d14b1812f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -32,6 +32,7 @@ mod jxl; mod jxl_encode; mod other; mod png_codec; +pub mod print; pub mod proof; mod psd; mod sniff; diff --git a/crates/codecs/src/print.rs b/crates/codecs/src/print.rs new file mode 100644 index 000000000..560e2710d --- /dev/null +++ b/crates/codecs/src/print.rs @@ -0,0 +1,375 @@ +//! Print colour: a composed print page (16-bit RGB encoded with the BT.2020 / BT.709 curve, as the +//! `Rec2020` output space renders it) to the samples a print-ready file carries, in the colour space +//! of the chosen printer / paper profile. +//! +//! - **Target**: a printer / paper ICC profile (RGB, CMYK or grey; matrix or LUT based) converts the +//! page to the device's own values. When the printer manages colour instead, the page is sent in a +//! well-known RGB space (sRGB, Adobe RGB) and that space's profile travels with it. +//! - **Rendering intent**: perceptual or relative colorimetric, as for soft proofing ([`ProofIntent`]). +//! - **Black point compensation** (relative colorimetric only): the darkest colour the device holds is +//! measured by a round trip of black through the profile, and the page's linear values are scaled +//! so its black lands there instead of every shadow below it clipping to the same ink. Linear +//! Rec.2020 is linear in XYZ, so with source black 0 and white 1 this is the published BPC mapping +//! `Y' = Y·(1 − Yb) + Yb` for a neutral destination black. Perceptual tables carry their own black +//! mapping and are left alone. +//! - **Print adjustment**: brightness and contrast (−100..100) for prints that come out darker or +//! flatter than the screen, on the sRGB-encoded values with black and white held. It is not shown +//! on screen (the print is what it corrects). +//! +//! Everything before the profile conversion is per channel, so it folds into one 65,536-entry table +//! indexed by the 16-bit sample; the conversion itself is moxcms's. +//! +//! Sources: ICC.1:2022 (rendering intents, profile classes); Adobe Systems, "Adobe Black Point +//! Compensation" (2006, public white paper) for the black point mapping. Own design otherwise. + +use std::sync::Arc; + +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor, TransformOptions}; + +use crate::display::MAX_PROFILE_BYTES; +use crate::proof::ProofIntent; +use crate::space::{NamedSpace, Trc}; + +/// Where a print's colours go. +#[derive(Clone, Debug, PartialEq)] +pub enum PrintTarget { + /// The printer manages colour: the page is sent in this RGB space, with its profile embedded. + Managed(NamedSpace), + /// A printer / paper ICC profile: the page is converted to the device's values. + Profile(Arc>), +} + +/// How the page is rendered into the target. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrintColor { + pub intent: ProofIntent, + /// Map the page's black to the device's darkest (relative colorimetric only). + pub black_point_compensation: bool, + /// −100..100; 0 leaves the page as rendered. + pub brightness: f32, + /// −100..100; 0 leaves the page as rendered. + pub contrast: f32, +} + +impl Default for PrintColor { + fn default() -> Self { + PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, brightness: 0.0, contrast: 0.0 } + } +} + +/// Pixels per conversion batch: bounds the temporary float buffers whatever the page size. +const BATCH: usize = 1 << 16; + +/// The darkest black point compensation will lift the page's black to. A profile whose round trip +/// says more is broken or not a print profile; leave the page alone rather than wash it out. +const MAX_BLACK: f32 = 0.5; + +type Executor = Arc + Send + Sync>; + +/// A page conversion, built once per print job and applied to every page. +pub struct PrintTransform { + forward: Executor, + layout: Layout, + channels: usize, + icc: Arc>, + /// 16-bit sample → linear Rec.2020 after the print adjustment and black point compensation. + pre: Vec, + black: f32, + description: Option, +} + +impl std::fmt::Debug for PrintTransform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PrintTransform") + .field("channels", &self.channels) + .field("black", &self.black) + .field("description", &self.description) + .finish_non_exhaustive() + } +} + +fn guarded(f: impl FnOnce() -> Option) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten() +} + +fn rendering_intent(intent: ProofIntent) -> RenderingIntent { + match intent { + ProofIntent::Perceptual => RenderingIntent::Perceptual, + ProofIntent::Relative => RenderingIntent::RelativeColorimetric, + } +} + +/// A slider value as a finite fraction in −1..1 (NaN and infinities count as 0). +fn amount(v: f32) -> f32 { + if v.is_finite() { (v / 100.0).clamp(-1.0, 1.0) } else { 0.0 } +} + +/// The print adjustment on one sRGB-encoded value (0..1): brightness bends the mid-tones with a +/// power curve, contrast steepens (or flattens) them with `e − k·sin(2πe)/2π`, which keeps black, +/// mid grey and white where they are and stays monotonic for |k| < 1. +pub fn print_adjust(e: f32, brightness: f32, contrast: f32) -> f32 { + let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 }; + let (b, c) = (amount(brightness), amount(contrast)); + let e = if b != 0.0 { e.powf(2f32.powf(-0.5 * b)) } else { e }; + let k = 0.6 * c; + let tau = std::f32::consts::TAU; + (e - k * (tau * e).sin() / tau).clamp(0.0, 1.0) +} + +impl PrintTransform { + /// Build the conversion for a target. Errors say what's wrong with the profile; nothing here + /// panics on a hostile one. + pub fn new(target: &PrintTarget, color: &PrintColor) -> Result { + let icc = match target { + PrintTarget::Managed(n) => Arc::new(crate::icc::write_named(*n)), + PrintTarget::Profile(bytes) => Arc::clone(bytes), + }; + if icc.len() > MAX_PROFILE_BYTES { + return Err("the profile is too large".into()); + } + let dev = guarded(|| ColorProfile::new_from_slice(&icc).ok()).ok_or("not a valid ICC profile")?; + let (layout, channels) = match dev.color_space { + DataColorSpace::Rgb => (Layout::Rgb, 3), + DataColorSpace::Cmyk => (Layout::Rgba, 4), + DataColorSpace::Gray => (Layout::Gray, 1), + _ => return Err("only RGB, CMYK and grey printer profiles can be printed to".into()), + }; + let lin = crate::icc::linear_rec2020_profile(); + let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() }; + let forward: Executor = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rendering_intent(color.intent))).ok()) + .ok_or("can't convert to this profile")?; + + let black = if color.black_point_compensation && color.intent == ProofIntent::Relative { + let back: Executor = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(RenderingIntent::RelativeColorimetric)).ok()) + .ok_or("can't convert from this profile")?; + device_black(&forward, &back, channels) + } else { + 0.0 + }; + + let decode = Trc::Rec709.lut16(); + let (b, c) = (color.brightness, color.contrast); + let adjust = amount(b) != 0.0 || amount(c) != 0.0; + let pre = decode + .iter() + .map(|&lin| { + let lin = if adjust { + let e = lightcraft_color::transfer::linear_to_srgb(lin.clamp(0.0, 1.0)); + lightcraft_color::transfer::srgb_to_linear(print_adjust(e, b, c)) + } else { + lin.clamp(0.0, 1.0) + }; + lin * (1.0 - black) + black + }) + .collect(); + let description = crate::icc::parse(&icc).and_then(|i| i.description); + Ok(PrintTransform { forward, layout, channels, icc, pre, black, description }) + } + + /// Samples per pixel of the converted page: 3 (RGB), 4 (CMYK) or 1 (grey). + pub fn channels(&self) -> usize { + self.channels + } + + /// The profile the converted samples are in: embed it with them. + pub fn icc(&self) -> &Arc> { + &self.icc + } + + /// Whether the samples are CMYK (PDF readers show CMYK images inverted unless told otherwise, + /// so writers need to know). + pub fn is_cmyk(&self) -> bool { + self.layout == Layout::Rgba + } + + /// The linear black black point compensation lifts the page's black to (0 when off). + pub fn black(&self) -> f32 { + self.black + } + + /// The profile's description, for the job's settings and error messages. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Convert a page: `rgb` is interleaved 16-bit RGB in the BT.2020 / BT.709 encoding; the result + /// is interleaved 16-bit device samples ([`PrintTransform::channels`] per pixel). + pub fn apply(&self, rgb: &[u16]) -> Result, String> { + if !rgb.len().is_multiple_of(3) { + return Err("the page isn't whole RGB pixels".into()); + } + let pixels = rgb.len() / 3; + let ch = self.channels; + let out_len = pixels.checked_mul(ch).ok_or("the page is too large")?; + let mut out = vec![0u16; out_len]; + let run = |src: &[u16], dst: &mut [u16]| -> Option<()> { + let lin: Vec = src.iter().map(|&v| self.pre.get(usize::from(v)).copied().unwrap_or(0.0)).collect(); + let mut dev = vec![0f32; dst.len()]; + self.forward.transform(&lin, &mut dev).ok()?; + for (d, v) in dst.iter_mut().zip(dev) { + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + *d = (v * 65535.0 + 0.5) as u16; + } + Some(()) + }; + let src = rgb.chunks(BATCH * 3); + let dst = out.chunks_mut(BATCH * ch); + #[cfg(feature = "parallel")] + let ok = { + use rayon::prelude::*; + let pairs: Vec<_> = src.zip(dst).collect(); + pairs.into_par_iter().all(|(s, d)| guarded(|| run(s, d)).is_some()) + }; + #[cfg(not(feature = "parallel"))] + let ok = src.zip(dst).all(|(s, d)| guarded(|| run(s, d)).is_some()); + if ok { Ok(out) } else { Err("the profile's tables can't be applied".into()) } + } +} + +/// The darkest the device holds, as linear Rec.2020 luminance: black there and back, relative +/// colorimetric both ways (for a device that reaches true black, 0). +fn device_black(forward: &Executor, back: &Executor, channels: usize) -> f32 { + let mut device = vec![0f32; channels]; + let mut lin = [0f32; 3]; + let ok = guarded(|| { + forward.transform(&[0.0; 3], &mut device).ok()?; + back.transform(&device, &mut lin).ok() + }); + if ok.is_none() { + return 0.0; + } + // BT.2020 luminance weights + let y = 0.2627 * lin[0] + 0.6780 * lin[1] + 0.0593 * lin[2]; + if y.is_finite() { y.clamp(0.0, MAX_BLACK) } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use moxcms::ToneReprCurve; + + /// A page of one 16-bit pixel per linear grey level. + fn greys(levels: &[f32]) -> Vec { + levels.iter().flat_map(|&l| [(Trc::Rec709.from_linear(l) * 65535.0).round() as u16; 3]).collect() + } + + fn convert(target: &PrintTarget, color: PrintColor, levels: &[f32]) -> Vec { + PrintTransform::new(target, &color).unwrap().apply(&greys(levels)).unwrap() + } + + /// An RGB print profile whose ink can't get darker than linear 0.03: device 0 is 3 % grey. + fn raised_black_profile() -> Arc> { + let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::Srgb)).unwrap(); + let (g, e) = (2.2f32, 0.03f32); + let a = (1.0 - e).powf(1.0 / g); + // ICC parametric type 4: Y = (aX + b)^g + e for X >= d, Y = cX + f below + let trc = ToneReprCurve::Parametric(vec![g, a, 0.0, 0.0, 0.0, e, e]); + p.red_trc = Some(trc.clone()); + p.green_trc = Some(trc.clone()); + p.blue_trc = Some(trc); + Arc::new(p.encode().unwrap()) + } + + #[test] + fn managed_srgb_keeps_black_grey_and_white() { + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor::default()).unwrap(); + assert_eq!(t.channels(), 3); + assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::Srgb)); + let out = t.apply(&greys(&[0.0, 0.18, 1.0])).unwrap(); + let mid = (lightcraft_color::transfer::linear_to_srgb(0.18) * 65535.0) as i32; + assert!(out[..3].iter().all(|&v| v < 64), "{out:?}"); + assert!(out[3..6].iter().all(|&v| (i32::from(v) - mid).abs() < 300), "{out:?} vs {mid}"); + assert!(out[6..].iter().all(|&v| v > 65400), "{out:?}"); + } + + #[test] + fn adobe_rgb_target_embeds_its_profile() { + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::AdobeRgb), &PrintColor::default()).unwrap(); + assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::AdobeRgb)); + // a saturated Rec.2020 green is outside Adobe RGB: it comes out at the space's edge, not wrapped + let px = [0u16, 65535, 0]; + let out = t.apply(&px).unwrap(); + assert!(out[1] > 60000 && out[0] < 8000 && out[2] < 8000, "{out:?}"); + } + + #[test] + fn brightness_lifts_the_mid_tones_and_holds_the_ends() { + let target = PrintTarget::Managed(NamedSpace::Srgb); + let levels = [0.0, 0.18, 1.0]; + let plain = convert(&target, PrintColor::default(), &levels); + let bright = convert(&target, PrintColor { brightness: 50.0, ..PrintColor::default() }, &levels); + let dark = convert(&target, PrintColor { brightness: -50.0, ..PrintColor::default() }, &levels); + assert!(bright[3] > plain[3] + 2000 && dark[3] + 2000 < plain[3], "{dark:?} {plain:?} {bright:?}"); + for v in [&plain, &bright, &dark] { + assert!(v[0] < 64 && v[6] > 65400, "{v:?}"); + } + } + + #[test] + fn contrast_spreads_shadows_and_highlights_and_stays_monotonic() { + let target = PrintTarget::Managed(NamedSpace::Srgb); + let levels = [0.03, 0.6]; + let plain = convert(&target, PrintColor::default(), &levels); + let more = convert(&target, PrintColor { contrast: 60.0, ..PrintColor::default() }, &levels); + assert!(more[0] < plain[0] && more[3] > plain[3], "{plain:?} {more:?}"); + for c in [-100.0, 100.0] { + for b in [-100.0, 100.0] { + let ramp: Vec = (0..=64).map(|i| i as f32 / 64.0).map(|e| print_adjust(e, b, c)).collect(); + assert!(ramp.windows(2).all(|w| w[1] >= w[0]), "b {b} c {c}: {ramp:?}"); + assert!(ramp[0] == 0.0 && (ramp[64] - 1.0).abs() < 1e-6); + } + } + } + + #[test] + fn black_point_compensation_keeps_shadow_detail_above_the_inks_black() { + let target = PrintTarget::Profile(raised_black_profile()); + let shadows = [0.0, 0.006, 0.012, 0.018, 0.024, 0.5, 1.0]; + let relative = PrintColor { intent: ProofIntent::Relative, black_point_compensation: false, ..PrintColor::default() }; + let clipped = convert(&target, relative, &shadows); + let kept = convert(&target, PrintColor { black_point_compensation: true, ..relative }, &shadows); + let reds = |v: &[u16]| v.chunks(3).map(|p| p[0]).collect::>(); + let (clipped, kept) = (reds(&clipped), reds(&kept)); + // everything below the ink's black prints as (within rounding) the same device value without + // compensation … + assert!(clipped[..5].iter().all(|&v| v <= clipped[0] + 16), "{clipped:?}"); + // … and as distinct, rising values with it; white still prints as white + assert!(kept[..5].windows(2).all(|w| w[1] > w[0]) && kept[4] > kept[0] + 1000, "{kept:?}"); + assert!(kept[6] > 65400 && clipped[6] > 65400, "{kept:?} {clipped:?}"); + let t = PrintTransform::new(&target, &PrintColor { black_point_compensation: true, ..relative }).unwrap(); + assert!((t.black() - 0.03).abs() < 0.01, "{}", t.black()); + } + + #[test] + fn black_point_compensation_is_relative_colorimetric_only() { + let target = PrintTarget::Profile(raised_black_profile()); + let levels = [0.0, 0.01, 0.2]; + let with = convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, ..PrintColor::default() }, &levels); + let without = + convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: false, ..PrintColor::default() }, &levels); + assert_eq!(with, without); + let t = PrintTransform::new(&target, &PrintColor { intent: ProofIntent::Perceptual, ..PrintColor::default() }).unwrap(); + assert_eq!(t.black(), 0.0); + } + + #[test] + fn hostile_input_is_an_error_not_a_panic() { + let color = PrintColor::default(); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![])), &color).is_err()); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; 400])), &color).is_err()); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; MAX_PROFILE_BYTES + 1])), &color).is_err()); + let good = crate::icc::write_named(NamedSpace::DisplayP3); + for i in (0..good.len()).step_by(7) { + let mut b = good.clone(); + b[i] ^= 0x5a; + if let Ok(t) = PrintTransform::new(&PrintTarget::Profile(Arc::new(b)), &PrintColor { intent: ProofIntent::Relative, ..color }) { + let _ = t.apply(&greys(&[0.0, 0.5, 1.0])); + } + } + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor { brightness: f32::NAN, contrast: f32::INFINITY, ..color }) + .unwrap(); + assert!(t.apply(&[1, 2]).is_err(), "half a pixel"); + assert_eq!(t.apply(&greys(&[0.18])).unwrap(), convert(&PrintTarget::Managed(NamedSpace::Srgb), color, &[0.18])); + assert!(print_adjust(f32::NAN, 50.0, 50.0).is_finite()); + } +} diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/corpus.rs index d3b2ceba3..9a86bc5e2 100644 --- a/crates/codecs/tests/corpus.rs +++ b/crates/codecs/tests/corpus.rs @@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec { fn corpus_images_decode() { let dir = corpus_root().join("images"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let (mut ok, mut unsupported) = (0, 0); @@ -57,7 +57,7 @@ fn corpus_images_decode() { fn corpus_pngsuite() { let dir = corpus_root().join("pngsuite"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let mut n = 0; @@ -83,7 +83,7 @@ fn corpus_pngsuite() { fn corpus_raw_is_routed() { let dir = corpus_root().join("raw"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } for p in files(&dir) { @@ -103,7 +103,7 @@ fn system_icc_profiles() { use lightcraft_codecs::icc::{IccColorModel, IccKind, parse}; let dir = Path::new("/System/Library/ColorSync/Profiles"); if !dir.is_dir() { - eprintln!("skip: no system ICC profiles"); + lightcraft_testkit::skip!(System, "no system ICC profiles"); return; } let expect = [ diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 8878f670f..ed20cd605 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -52,6 +52,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # HDR JPEG XL tests read the file's colour encoding jxl-oxide = { workspace = true } # Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on). diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 2e3f9a623..19e511ce3 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -79,10 +79,41 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from +/// `warp` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from /// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> (Xy, CameraTransform, Option) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)) +pub(crate) fn starting_colour( + raw: &mut RawImage, + bytes: &[u8], + lens: Option<&lightcraft_develop::EmbeddedLens>, + warp: Option<&LensGeometry>, +) -> (Xy, CameraTransform, Option) { + let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, warp), |raw, t| monochrome_look(raw, bytes, t, warp)); + if look.is_none() && dng_tone_eligible(raw, &t) { + let look = fit_dng_tone(raw, bytes, &t, lens); + return (xy, t, look); + } + (xy, t, look) +} + +/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). +fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { + curve.is_none_or(|c| { + (0..=32).all(|i| { + let x = i as f32 / 32.0; + (c.eval(x) - x).abs() < 0.005 + }) + }) +} + +/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no +/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). +pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { + let p = &raw.color.profile; + raw.format == RawFormat::Dng + && !t.matrix_is_fallback + && identity_tone_curve(p.tone_curve.as_ref()) + && p.look_table.is_none() + && p.gain_table_map.is_none() } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white @@ -131,7 +162,7 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option bool { /// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px); /// 128 MP also admits 100 MP medium-format previews. const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; + +/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model +/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near +/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than +/// the plain render ([`clips_more`]). +fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option { + // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. + let refuse = |why: &str| { + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE refused {why}"); + } + None + }; + let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") }; + // As the loader renders it: the profile's tables around the baseline exposure. + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { + let gain = 2f32.powf(transform.baseline_exposure as f32); + sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); + } + correct_vignetting(&mut sensor, lens, raw.orientation); + if (sensor.width, sensor.height) != (reference.width, reference.height) { + return refuse("preview"); + } + if !structure_agrees(&sensor, &reference) { + return refuse("structure"); + } + let unclipped = without_clipped(&sensor, &clipped); + let fit_on = unclipped.as_ref().unwrap_or(&sensor); + let colour = Some((Mat3::IDENTITY, None)); + let look = TONE_FITS.iter().find_map(|&tone| { + [false, true] + .into_iter() + .find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) + }); + let Some(look) = look else { return refuse("fit") }; + let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { + Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), + None => ToneMap::new(0.0, 0.0, 0.0), + }; + let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if clips_more(&sensor, &reference, &fitted, &plain) { + return refuse("clip"); + } + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE accepted"); + } + Some(look) +} + +/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain +/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the +/// camera's look, one that clips what neither does loses highlight detail. +fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { + let near_white = |tone: &ToneMap| { + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + n as f64 / sensor.data.len().max(1) as f64 + }; + let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; + near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE +} + +/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the +/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the +/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. +fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { + let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; + let (w, h) = (sensor.width as f64, sensor.height as f64); + let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; + let width = sensor.width.max(1); + for (i, p) in sensor.data.iter_mut().enumerate() { + let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); + let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; + *p = p.map(|v| v * gain); + } +} + +/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a +/// small thumbnail is not fitted. +const MIN_PREVIEW_FRACTION: f64 = 0.9; + +/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark +/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. +const DARK_FLOOR: (f32, f32) = (0.004, 0.001); + +/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's +/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits +/// that clip about 1% more than the camera does and passes the ones that clip like the camera). +const CLIP_TOLERANCE: f64 = 0.001; + +/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. +const MIN_STRUCTURE: f64 = 0.85; + +/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance +/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) +/// correlates better. +fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { + let (w, h) = (sensor.width, sensor.height); + if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { + return false; + } + let log = |img: &Rgb32f| -> Vec { + let l: Vec = + img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (mut sum, mut n) = (0.0, 0.0); + for yy in y.saturating_sub(2)..(y + 3).min(h) { + for xx in x.saturating_sub(2)..(x + 3).min(w) { + if let Some(v) = l.get(yy * w + xx) { + sum += v; + n += 1.0; + } + } + } + sum / f64::max(n, 1.0) + }) + .collect() + }; + let (a, b) = (log(sensor), log(reference)); + const M: usize = 3; + let corr = |dx: isize, dy: isize| -> f64 { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for y in M..h - M { + for x in M..w - M { + let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); + let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; + n += 1.0; + sa += u; + sb += v; + saa += u * u; + sbb += v * v; + sab += u * v; + } + } + let var = (saa - sa * sa / n) * (sbb - sb * sb / n); + if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } + }; + let at_zero = corr(0, 0); + let (mut best, mut shift) = (at_zero, (0isize, 0isize)); + for dy in -(M as isize)..=M as isize { + for dx in -(M as isize)..=M as isize { + let c = corr(dx, dy); + if c > best + 1e-9 { + (best, shift) = (c, (dx, dy)); + } + } + } + at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 +} + /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. @@ -398,6 +582,7 @@ fn proxies( bytes: &[u8], transform: &CameraTransform, size: usize, + min_fraction: f64, lens: Option<&LensGeometry>, ) -> Option<(Rgb32f, Rgb32f, Vec)> { let edge = (2 * size).max(384) as u32; @@ -406,8 +591,13 @@ fn proxies( lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS }, None, )?; - let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); + // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) + { + return None; + } + let mut reference = decoded.to_working(); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { return None; } @@ -623,11 +813,11 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeom return None; } let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); - let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, lens)?; + let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?; let to_camera = transform.matrix.inverse()?; // clipped sensor pixels don't record the scene's colour (see `fit_look`) let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); - let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?; + let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?; Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect()) } @@ -795,7 +985,7 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 { /// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated /// colours (see [`EDGE_CONTRAST`]). type Pairs = Vec<([f64; 3], [f64; 3])>; -fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option) -> Option<(Pairs, Pairs)> { +fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option, dark: bool) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; } @@ -814,7 +1004,8 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi if (0.015..=1.0).contains(&y) { bright.push((input.map(f64::from), output.map(f64::from))); } - if !colour_pair(*input, *output) { + let floors = if dark { DARK_FLOOR } else { (0.015, 0.004) }; + if !colour_pair_above(*input, *output, floors) { continue; } let min = output.iter().copied().fold(f32::INFINITY, f32::min); @@ -827,9 +1018,15 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi /// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting. fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool { + colour_pair_above(input, output, (0.015, 0.004)) +} + +/// [`colour_pair`] with its lowest preview luminance and channel value given (`floors`: the usual 0.015 and 0.004, or +/// [`DARK_FLOOR`] for the tone-only fit of a DNG). +fn colour_pair_above(input: [f32; 3], output: [f32; 3], (ylo, clo): (f32, f32)) -> bool { input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) - && output.iter().all(|v| *v > 0.004 && *v < 0.98) - && (0.015..0.85).contains(&luminance_2020(output)) + && output.iter().all(|v| *v > clo && *v < 0.98) + && (ylo..0.85).contains(&luminance_2020(output)) } /// Fit tone independently of colour eligibility when enough dark JPEG signal is available. @@ -966,6 +1163,8 @@ struct Attempt { tone: ToneFit, profile: bool, away_from_edges: bool, + /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. + dark: bool, } /// The photo's look and the attempt that produced it: its own matrix (with and without a @@ -982,7 +1181,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt order.iter().find_map(|&tone| { profiles.iter().find_map(|&profile| { [false, true].into_iter().find_map(|away_from_edges| { - let attempt = Attempt { tone, profile, away_from_edges }; + let attempt = Attempt { tone, profile, away_from_edges, dark: false }; fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt)) }) }) @@ -991,7 +1190,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, attempt: Attempt) -> Option { let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone) + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark) } fn fit_pairs_on( @@ -1000,10 +1199,11 @@ fn fit_pairs_on( colour: Option<(Mat3, Option)>, edge_limit: Option, tone_fit: ToneFit, + dark: bool, ) -> Option { // A known colour model needs enough signal for tone fitting, not a scene rich enough to // learn a new colour matrix. Still reject monochrome references. - let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; let candidates = match colour { Some(given) => vec![given], None => { @@ -1147,7 +1347,7 @@ fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option { fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option) -> Option { // as for a known colour model: enough signal for the tone, but no monochrome reference - let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; let related = rank_correlation(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1225,7 +1425,7 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, lens)?; + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; if !monochrome(&reference) { return None; } @@ -1256,7 +1456,7 @@ fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opti fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option)>, edge_limit: Option) -> Option { // every pair counts: the JPEG has no colour to require - let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?; + let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; let related = rank_correlation_ties(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1350,6 +1550,42 @@ impl<'a> HueSat<'a> { } std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) } + + /// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through + /// [`HsvTable::apply_lanes`], with the guards as selects. + #[inline] + pub fn apply_lanes(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| { + let mut out = [[0.0f32; N]; 3]; + for (row, out) in m.iter().zip(&mut out) { + for l in 0..N { + out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l]; + } + } + out + }; + let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb))); + let mut restored = rgb; + for l in 0..N { + let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]); + let (before, after) = (luminance_2020(input), luminance_2020(out)); + let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite()); + let w = restore_weight(after / before); + for (c, restored) in restored.iter_mut().enumerate() { + let full = out[c] * before / after; + let blend = input[c] + (full - input[c]) * w; + // (`w >= 1` and `w <= 0` exclude each other) + restored[l] = if untouched | (w <= 0.0) { + input[c] + } else if w >= 1.0 { + full + } else { + blend + }; + } + } + restored + } } /// HSV hue (degrees) and saturation; `None` for black or non-finite colours. @@ -1659,7 +1895,7 @@ mod tests { fn colour_holdouts_stay_out_of_the_wider_tone_training() { let (sensor, reference) = steep_toe_scene(); let fit = |reference: &Rgb32f| { - let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap(); + let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap(); fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap() }; let expected = fit(&reference); @@ -1716,7 +1952,7 @@ mod tests { *dst = [y * slope; 3]; assert_eq!(colour_pair(*src, *dst), i >= 600); } - let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap(); + let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).unwrap(); let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap(); // Both sample sets start at a multiple of three, so this split matches // the helper's separate colour and extra-sample indices. @@ -1826,8 +2062,10 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata::default(), + lens_metadata: Default::default(), }; let proxy = sensor_proxy(&raw, 2, 384).unwrap(); assert_eq!((proxy.width, proxy.height), (32, 32)); @@ -2017,7 +2255,7 @@ mod tests { let mut pool = Vec::new(); for seed in 0..4 { let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); - pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); } let (matrix, _) = fit_profile(&pool).unwrap(); // the camera's colours (luminance-normalised: the tone curve sets brightness) @@ -2121,6 +2359,51 @@ mod tests { assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); } + /// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey, + /// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table. + #[test] + fn hue_sat_lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0; + let fitted = (0..72 * 5 * 5) + .map(|_| { + let r = next(); + [unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0] + }) + .collect(); + let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true }; + let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]; + let mut colours = Vec::new(); + for a in special { + for b in special { + colours.extend(special.map(|c| [a, b, c])); + } + } + for _ in 0..60_000 { + let r = next(); + let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize]; + colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset)); + } + for table in [fitted, saturating] { + let hue_sat = HueSat::new(&table).unwrap(); + for chunk in colours.chunks(16) { + let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c]))); + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } + } + #[test] fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { assert_eq!(restore_weight(0.0), 0.0); @@ -2169,8 +2452,10 @@ mod tests { color, wb_multipliers: Some([2.0, 1.0, 1.5]), linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, + lens_metadata: Default::default(), } } @@ -2246,7 +2531,7 @@ mod tests { raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) = - (proxies(&raw, &bytes, &t, PROXY, None), proxies(&raw, &bytes, &t, PROXY, Some(&lens))) + (proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens))) else { continue; }; @@ -2308,7 +2593,7 @@ mod tests { assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[], None); + let (_, t, look) = starting_colour(&mut raw, &[], None, None); assert!(look.is_none()); assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); assert!(!t.matrix_is_fallback); @@ -2367,13 +2652,13 @@ mod tests { #[test] fn a_camera_outside_the_table_keeps_the_fallback() { let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[], None); + let (_, t, look) = starting_colour(&mut raw, &[], None, None); assert!(look.is_none() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); // without a model there is no table row let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback); + assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback); } /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is @@ -2388,7 +2673,7 @@ mod tests { .join("raw"); for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let mut raw = lightcraft_raw::decode(&bytes).unwrap(); @@ -2399,7 +2684,7 @@ mod tests { let measured = lightcraft_raw::spectral::find(make, model).is_some(); let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref()); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, None, lens.as_ref()); let spectral = !own && fitted.is_none() && measured; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); @@ -2493,13 +2778,13 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/cr3-canon-r100-raw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = match lightcraft_raw::decode(&bytes) { Ok(raw) => raw, Err(lightcraft_raw::RawError::Unsupported(why)) => { - eprintln!("skip CR3 sensor decoder: {why}"); + lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); return; } Err(error) => panic!("CR3 corpus failed to decode: {error}"), @@ -2528,7 +2813,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; } Err(e) => panic!("{}: {e}", path.display()), @@ -2581,7 +2866,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d7500-lossless14.nef"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2601,7 +2886,7 @@ mod tests { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -2622,7 +2907,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2641,7 +2926,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2731,6 +3016,36 @@ mod tests { renders_as_shot(&dng); } + /// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without + /// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking + /// it from the multipliers again leaves the merged DNG rendering as the tags do. + #[test] + fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { + let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); + raw.wb_multipliers = Some(multipliers); + let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); + let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); + // the reciprocal of the multipliers, up to the scale a neutral is given in + let scale = neutral[1] * multipliers[1] as f64; + for i in 0..3 { + assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); + } + let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); + merged.set_as_shot_multipliers(multipliers); + let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; + let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); + for i in 0..3 { + assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); + } + for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { + assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); + } + assert_eq!(a.baseline_exposure, b.baseline_exposure); + } + } + /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { CameraLook { @@ -2882,7 +3197,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } Err(e) => panic!("{}: {e}", path.display()), @@ -2893,7 +3208,7 @@ mod tests { let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look"); // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, None).unwrap(); + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap(); let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); @@ -3189,7 +3504,7 @@ mod tests { assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap(); - assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false }); + assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); } @@ -3452,8 +3767,10 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, + lens_metadata: Default::default(), }; assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG @@ -3525,4 +3842,145 @@ mod tests { let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); assert!(info.camera_tone.is_some(), "no camera look fitted"); } + + /// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. + fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { + let scene = |x: f32, y: f32| -> [f32; 3] { + let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; + [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); + } + } + (sensor, reference) + } + + /// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a + /// flat and a differently sized preview. + #[test] + fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { + let (sensor, reference) = scene_pair(96, 64, 0); + assert!(structure_agrees(&sensor, &reference)); + assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); + assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); + let mut stripes = reference.clone(); + for (i, p) in stripes.data.iter_mut().enumerate() { + *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; + } + assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); + let mut flat = reference.clone(); + flat.data.fill([0.2; 3]); + assert!(!structure_agrees(&sensor, &flat)); + assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); + } + + /// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that + /// renders mid-grey brighter than the default tone map is followed. + #[test] + fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { + let (mut sensor, mut reference) = scene_pair(96, 64, 0); + for image in [&mut sensor, &mut reference] { + for p in image.data.iter_mut().step_by(5) { + *p = [p[0] * 1.6, p[1], p[2] * 0.5]; + } + } + let colour = Some((Mat3::IDENTITY, None)); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }) + .expect("tone fitted"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let target = 1.0 - (-2.5f32 * 0.18).exp(); + assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); + assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); + assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); + } + + /// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the + /// camera preview's, in one direction only: clipping what the camera clips is not refused. + #[test] + fn dng_highlight_gate_compares_near_white_shares() { + let mut sensor = Rgb32f::new(32, 32); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; + } + let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); + let mut white = Rgb32f::new(32, 32); + white.data.iter_mut().for_each(|p| *p = [1.0; 3]); + let mut black = Rgb32f::new(32, 32); + black.data.iter_mut().for_each(|p| *p = [0.1; 3]); + assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); + assert!(!clips_more(&sensor, &black, &dark, &bright)); + assert!(!clips_more(&sensor, &black, &bright, &bright)); + assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); + } + + /// A dark scene's shadows below the usual training floor still reach the tone-only fit. + #[test] + fn dng_tone_fit_trains_on_dark_pixels() { + let mut sensor = Rgb32f::new(64, 64); + let mut reference = Rgb32f::new(64, 64); + for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { + let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; + *s = [v, v * 1.1, v * 0.9]; + *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; + } + let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); + let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); + assert!(dark > usual, "{dark} vs {usual}"); + } + + /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. + #[test] + fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let lens = EmbeddedLens { + vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() + }; + let mut proxy = Rgb32f::new(96, 64); + proxy.data.fill([0.2; 3]); + correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); + let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); + assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); + let mut plain = Rgb32f::new(96, 64); + plain.data.fill([0.2; 3]); + correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); + assert!(plain.data.iter().all(|p| *p == [0.2; 3])); + } + + /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the + /// identity, no look table; and no other raw format. + #[test] + fn dng_tone_gates_follow_the_files_own_tags() { + use lightcraft_raw::profile::{HsvTable, ToneCurve}; + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { + let raw = raw_of(format, "Make", "Model", color); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + dng_tone_eligible(&raw, &t) + }; + assert!(eligible(RawFormat::Dng, color.clone())); + assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); + assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); + let mut identity = color.clone(); + identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); + assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); + let mut curved = color.clone(); + curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); + let mut looked = color; + looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); + assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering"); + } } diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 49f6e46e9..655965688 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -224,7 +224,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result { /// The browsed photos among `ids` (default: the selection) become library photos. fn add_to_library(s: &mut Session, p: &Value) -> Result { - let ids: Vec = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); + let ids: Vec = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); if ids.is_empty() { return Ok(json!({"added": 0})); } @@ -410,7 +410,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result { if !dir.is_dir() { return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)"))); } - let ids = s.targets(p); + let ids = s.targets(p)?; if ids.is_empty() { return Err(bad(C, "no photos to move: select some or give ids")); } diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 20ed66edd..79d732284 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -59,7 +59,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path fn convert(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.convertToDng"; - let ids = s.targets(p); + let ids = s.targets(p)?; let mut ops = Vec::new(); let mut converted = Vec::new(); let mut skipped = Vec::new(); @@ -125,7 +125,7 @@ pub fn specs() -> Vec { /// the library and stack it on top of the original. The host opens the file in the editor. fn edit_external(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.editExternal"; - let mut ids = s.targets(p); + let mut ids = s.targets(p)?; ids.dedup(); if ids.is_empty() { return Err(bad(C, "no active photo")); @@ -311,7 +311,7 @@ pub(crate) fn reload_ops( /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { - let ids = s.targets(p); + let ids = s.targets(p)?; let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids .iter() .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) { @@ -340,7 +340,7 @@ fn reload(s: &mut Session, p: &Value) -> Result { fn duplicate(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.duplicate"; let mut made = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue }; let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) }; let src = Path::new(path); @@ -514,7 +514,7 @@ mod tests { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); let f = root.join("raw/rw2-panasonic-gx80.rw2"); if !f.exists() { - eprintln!("skip: {} absent", f.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", f.display()); return; } let mut s = Session::new().with_fs(); diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 51049797e..676519d29 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -20,7 +20,7 @@ fn secs(iso: &str) -> Option { } fn analyze(s: &mut Session, p: &Value) -> Result { - let ids: Vec = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let ids: Vec = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); // measure (thumbnail-level sources: fast, and enough for focus at 512 px) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 37406fab4..e3d59c567 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -254,7 +254,7 @@ pub fn specs() -> Vec { }) }), cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| { - let ids = s.targets(p); + let ids = s.targets(p)?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -304,7 +304,7 @@ pub fn specs() -> Vec { let targets = match named_photos(s, p, C)? { Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())), Some(ids) => ids, - None => s.targets(p), + None => s.targets(p)?, }; let ops: Vec = targets .into_iter() @@ -811,7 +811,7 @@ pub fn specs() -> Vec { s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?; let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone()); let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups); - let ids: Vec = s.targets(p).into_iter().filter(|id| *id != prev).collect(); + let ids: Vec = s.targets(p)?.into_iter().filter(|id| *id != prev).collect(); let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -838,7 +838,7 @@ pub fn specs() -> Vec { let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0); clip = lightcraft_develop::extract_groups(&full, &only); } - let ids = s.targets(p); + let ids = s.targets(p)?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -890,7 +890,7 @@ pub fn specs() -> Vec { let delta = f64_req(p, "delta", c)?; let label = format!("Quick Develop: {}", spec.label); let ops: Vec = s - .targets(p) + .targets(p)? .into_iter() .filter_map(|id| s.develop_of(id).map(|d| (id, d))) .filter_map(|(id, d)| { @@ -943,7 +943,7 @@ pub fn specs() -> Vec { }; let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?; let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0); - let ids: Vec = s.targets(p).into_iter().filter(|x| *x != reference).collect(); + let ids: Vec = s.targets(p)?.into_iter().filter(|x| *x != reference).collect(); let (mut ops, mut skipped) = (Vec::new(), Vec::new()); for id in ids { let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else { @@ -978,7 +978,8 @@ pub fn specs() -> Vec { let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?; let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?; let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0; - let ids = s.targets(p); + // `id` names the preset here: the photos are `ids`, else the selection + let ids = s.targets(&json!({"ids": p.get("ids")}))?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index 4676d1c65..2328a3db4 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -132,7 +132,7 @@ fn detect(s: &mut Session, p: &Value) -> Result { let detector = detector(s).map_err(|e| e.into_error(C))?; let started = Instant::now(); let (mut results, mut ops) = (Vec::new(), Vec::new()); - for id in s.targets(p) { + for id in s.targets(p)? { let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) { Ok(r) => r, Err(e) => { diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index 7e9e459cf..2c340c4ec 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -841,7 +841,7 @@ mod imp { const C: &str = "faces.suggest"; let embedder = current(s, C)?; collect(s); - let targets = s.targets(p); + let targets = s.targets(p)?; let model_threshold = embedder.manifest().thresholds.match_cosine; let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32); let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD); diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index ddbe6b13a..085183d58 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, fn reverse(s: &mut Session, p: &Value) -> Result { const C: &str = "metadata.reverseGeocode"; let overwrite = bool_or(p, "overwrite", false); - let ids = s.targets(p); + let ids = s.targets(p)?; let todo: Vec<(PhotoId, LatLon)> = ids .iter() .filter_map(|id| { diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index b2c7e90f0..fe09a92f9 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -256,7 +256,7 @@ pub fn specs() -> Vec { let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else { return Ok(json!({"changed": 0})); }; - let ids = s.targets(p); + let ids = s.targets(p)?; let all = !ids.is_empty() && ids.iter().all(|id| { s.catalog @@ -288,7 +288,7 @@ pub fn specs() -> Vec { always, |s, p| { let mut current: Vec = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { if let Some(ph) = s.catalog.photo(id) { current.extend(ph.meta.keywords.iter().cloned()); } @@ -336,7 +336,7 @@ pub fn specs() -> Vec { Some(parent) => format!("{parent}|{name}"), None => name, }); - let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() }; + let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() }; let op = s .catalog .create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index cc3e654c7..7b0e1e562 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -182,7 +182,7 @@ fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result /// Apply one op per target as a single undo step. fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option) -> Result { - let targets = s.targets(p); + let targets = s.targets(p)?; let ops: Vec = targets.iter().filter_map(|id| f(*id)).collect(); let n = ops.len(); if n > 0 { @@ -194,7 +194,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O /// Adjust each target's own rating; unchanged bounds do not create an undo step. fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result { let mut ops = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating; let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8; if rating != old { @@ -213,7 +213,7 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result Flag) -> Result { let mut ops = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag; let flag = step(old); if flag != old { @@ -258,7 +258,7 @@ fn row_step(p: &Value) -> isize { } fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { - let targets = s.targets(p); + let targets = s.targets(p)?; let mut ops = Vec::new(); for id in targets { if let Some(d) = s.develop_of(id) { @@ -277,7 +277,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result { } fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result { - let targets = s.targets(p); + let targets = s.targets(p)?; let mut ops = Vec::new(); for id in targets { if let Some(d) = s.develop_of(id) { @@ -883,7 +883,7 @@ pub fn specs() -> Vec { // ---- delete / restore cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { let before = s.visible_cloned(); - let targets = s.targets(p); + let targets = s.targets(p)?; let n = mark_deleted(s, &targets, "Delete")?; select_after_deletion(s, &before); Ok(json!({"changed": n})) @@ -912,7 +912,7 @@ pub fn specs() -> Vec { ), cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let before = s.visible_cloned(); - let t = s.targets(p); + let t = s.targets(p)?; let op = s.catalog.delete_photos_permanently_ops(&t); s.commit("Delete Permanently", op)?; select_after_deletion(s, &before); @@ -929,7 +929,7 @@ pub fn specs() -> Vec { |s, p| { let strs = |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::>()); - let targets = s.targets(p); + let targets = s.targets(p)?; // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear let gps: Option> = match p.get("gps") { None => None, @@ -1027,8 +1027,7 @@ pub fn specs() -> Vec { } let folder = bool_or(p, "folder", false); let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let (photos, skipped) = - if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) }; + let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) }; let id = s.catalog.alloc_album_id(); let cover = photos.first().copied(); s.commit( @@ -1268,7 +1267,7 @@ pub fn specs() -> Vec { has_library_selection, |s, p| { let id = album_param(p, "id", "album.addPhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = ids_param(p).unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; if al.is_smart() || al.folder { return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); @@ -1276,8 +1275,10 @@ pub fn specs() -> Vec { let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; let mut photos = al.photos.clone(); let before = photos.len(); + // looked up, not searched for: adding thousands to a big album stays linear + let mut held: std::collections::HashSet = photos.iter().copied().collect(); for t in targets { - if !photos.contains(&t) { + if held.insert(t) { photos.push(t); } } @@ -1295,7 +1296,7 @@ pub fn specs() -> Vec { "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", has_library_selection, |s, p| { - let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?; + let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?; let (targets, skipped) = targets; let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); let id = match target.or_else(|| s.catalog.quick_collection()) { @@ -1308,11 +1309,14 @@ pub fn specs() -> Vec { } }; let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; - let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t)); + // looked up, not searched for: thousands toggled in a big album stay linear + let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); + let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); let photos: Vec = if all_in { + let targets: std::collections::HashSet = targets.iter().copied().collect(); al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() } else { - al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect() + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() }; let name = al.name.clone(); let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); @@ -1454,7 +1458,7 @@ pub fn specs() -> Vec { _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), }, }; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = ids_param(p).unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; if al.is_smart() { return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); @@ -1463,6 +1467,7 @@ pub fn specs() -> Vec { return Err(bad("album.removePhotos", "album folders don't hold photos")); } let name = al.name.clone(); + let targets: std::collections::HashSet = targets.into_iter().collect(); let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); let removed = al.photos.len().saturating_sub(photos.len()); if removed > 0 { diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index 349e66ea8..f03809b62 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result { imported.push(json!({"id": id, "name": display, "group": group})); } s.save_prefs()?; + // presets that name these profiles use them now + crate::profile_refs::refresh(s); Ok(json!({"imported": imported, "failed": failed})) } @@ -160,6 +162,7 @@ pub fn specs() -> Vec { } lightcraft_pipeline::lut::unregister(&id); s.save_prefs()?; + crate::profile_refs::refresh(s); Ok(json!({"deleted": id})) }), ] diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index d06271525..66ddfe9fb 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -10,7 +10,7 @@ use crate::Result; fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec, String, usize)> { let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string(); let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize; - Ok((s.targets(p), template, start)) + Ok((s.targets(p)?, template, start)) } /// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name). @@ -96,7 +96,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result { let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0); let mut ops = Vec::new(); let mut photos = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let Some(ph) = s.catalog.photo(id) else { continue }; if ph.meta.gps.is_some() && !replace { has_gps += 1; @@ -183,7 +183,7 @@ pub fn specs() -> Vec { |s, p| { use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso}; let c = "photo.setCaptureTime"; - let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); + let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); if targets.is_empty() { return Err(bad(c, "no photos")); } diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index 708614453..c07065cde 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -20,7 +20,7 @@ fn can_merge(s: &Session) -> std::result::Result<(), String> { fn run(s: &mut Session, id: &str, p: &Value) -> Result { let (kind, finish) = crate::merge::parse(id, p)?; - let ids: Vec = s.targets(p); + let ids: Vec = s.targets(p)?; if !bool_or(p, "preview", false) { return s.merge_now(kind, finish, &ids); } @@ -45,7 +45,7 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result { fn run_batch(s: &mut Session, p: &Value) -> Result { const ID: &str = "merge.hdrBatch"; let (kind, finish) = crate::merge::parse("merge.hdr", p)?; - let ids: Vec = s.targets(p); + let ids: Vec = s.targets(p)?; let sets = s.catalog.bracket_sets(&ids); if sets.is_empty() { return Err(bad(ID, crate::merge::NO_BRACKET_SETS)); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 41e3b0929..2f340eda7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -34,6 +34,7 @@ mod organize; mod prefs; mod preset_files; pub mod previews; +mod print; mod query; pub mod recheck; mod slideshow; @@ -109,7 +110,7 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> { /// can take (issue #684; Add to My Photos first). pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; - if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } + if s.selected().iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } } /// Why an album command refused a Local-only selection. pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)"; @@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(prefs::specs()); v.extend(slideshow::specs()); v.extend(export::specs()); + v.extend(print::specs()); v.extend(before::specs()); v.extend(browse::specs()); v.extend(missing::specs()); diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 494785628..24cc2061c 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -22,7 +22,7 @@ fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec { fn in_stack(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; - if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } + if stacks_of(s, &s.selected()).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } } fn has_stacks(s: &Session) -> std::result::Result<(), String> { @@ -111,7 +111,7 @@ pub fn specs() -> Vec { "{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"", has_selection, |s, p| { - let targets = s.targets(p); + let targets = s.targets(p)?; let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string); let mut made = Vec::new(); for t in targets { @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged", has_selection, |s, p| { - let targets = s.targets(p); + let targets = s.targets(p)?; let top = p .get("top") .and_then(Value::as_u64) @@ -153,11 +153,11 @@ pub fn specs() -> Vec { } ), cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| { - let ops = s.catalog.ungroup_ops(&s.targets(p)); + let ops = s.catalog.ungroup_ops(&s.targets(p)?); Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?})) }), cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| { - let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)); + let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)?); Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?})) }), cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| { @@ -189,7 +189,7 @@ pub fn specs() -> Vec { } ), cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { - let stacks = stacks_of(s, &s.targets(p)); + let stacks = stacks_of(s, &s.targets(p)?); let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); let collapsed = bool_or(p, "collapsed", !first); set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) @@ -212,7 +212,7 @@ pub fn specs() -> Vec { |s, p| { let gap = f64_or(p, "gap", 60.0).max(0.0); let explicit = p.get("ids").is_some(); - let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let groups = s.catalog.auto_stack_groups(&pool, gap); let photos: usize = groups.iter().map(Vec::len).sum(); if bool_or(p, "preview", false) { diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index f65c0e374..b64a69fc7 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -57,10 +57,20 @@ fn import(s: &mut Session, p: &Value) -> Result { if let Some(g) = &group { it.preset.group = g.clone(); } + // the profile it names (issue #1001): one the user imported, or ours for a basic one + let mut profile = Value::Null; + if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) { + let look = it.preset.settings["profile"]["look"].clone(); + it.unmapped.retain(|u| u != "Look"); + it.unmapped.extend(crate::profile_refs::report(&look, &r)); + profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")}); + } let (name, grp) = (it.preset.name.clone(), it.preset.group.clone()); let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() }; match id { - Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})), + Some(id) => { + imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile})) + } None => skipped += 1, } } @@ -108,7 +118,7 @@ pub fn specs() -> Vec { "Import Presets", [], None, - "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups) → {imported: [{id,name,group,file,unmapped}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}", + "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups); a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}", always, import ), diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 4fc7ababa..b63ccc2b4 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -152,7 +152,7 @@ fn build(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "a preview build is already running")); } // explicit ids, else the selection, else everything in view - let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let jobs: Vec> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); @@ -324,7 +324,7 @@ fn smart(s: &mut Session, p: &Value) -> Result { if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) { return Err(bad(C, "a preview build is already running")); } - let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let mut jobs: Vec = Vec::new(); for id in ids { let Some(ph) = s.catalog.photo(id).cloned() else { continue }; @@ -503,7 +503,7 @@ pub fn specs() -> Vec { smart_location ), cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| { - let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; + let id = s.targets(p)?.first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; let online = match s.catalog.photo(id).map(|p| p.source.clone()) { Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(), _ => true, diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs new file mode 100644 index 000000000..05a851893 --- /dev/null +++ b/crates/engine/src/cmd/print.rs @@ -0,0 +1,353 @@ +//! `print.*`: the Print module as commands. +//! +//! The module's state is [`Session::print`] (layout, image settings, page, guides, job), so the +//! app, CLI, control channel and MCP share one print. Lengths are inches in page space (origin at +//! the top-left of the page as printed); cell ids and photo ids are numbers. Nothing here touches +//! the catalog, so none of it is journaled or undoable; saved templates live in prefs.json. +//! +//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset` +//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear` +//! * `print.exportPdf {path, ids?}` +//! * `print.template.list|save|apply|delete` +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, f64_req}; +use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size}; +use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name}; +use crate::{Result, Session}; + +fn rect_json(r: &Rect) -> Value { + json!({"x": r.x, "y": r.y, "w": r.w, "h": r.h}) +} + +/// The state, with what a front end needs to draw it (page size, printable area, grid cells). +fn state(s: &Session, photos: usize) -> Value { + let l = &s.print.layout; + let mut out = json!({ + "settings": s.print, + "papers": paper_names().filter_map(|n| paper_size(n).map(|(w, h)| json!({"name": n, "width": w, "height": h}))).collect::>(), + "overlaps": l.has_overlaps(), + "photos": photos, + }); + if let (Ok((w, h)), Ok(area), Some(o)) = (l.page_size(), l.printable(), out.as_object_mut()) { + o.insert("pageWidth".into(), json!(w)); + o.insert("pageHeight".into(), json!(h)); + o.insert("printable".into(), rect_json(&area)); + o.insert("pageCount".into(), json!(l.page_count(photos))); + o.insert("cellsPerPage".into(), json!(l.cells_per_page())); + if !l.style.is_package() { + o.insert("gridCells".into(), json!(l.grid_rects().unwrap_or_default().iter().map(rect_json).collect::>())); + } + } + out +} + +fn reply(s: &Session) -> Result { + Ok(state(s, s.targets(&Value::Null)?.len())) +} + +/// Applies `patch` over `dst`: objects merge key by key, anything else replaces. +fn merge(dst: &mut Value, patch: &Value, depth: usize) { + match (dst, patch) { + (Value::Object(d), Value::Object(p)) if depth < 8 => { + for (k, v) in p { + match d.get_mut(k) { + Some(slot) => merge(slot, v, depth + 1), + None => { + d.insert(k.clone(), v.clone()); + } + } + } + } + (d, p) => *d = p.clone(), + } +} + +fn get(s: &mut Session, p: &Value) -> Result { + Ok(state(s, s.targets(p)?.len())) +} + +fn set(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.set"; + let patch = p.as_object().ok_or_else(|| bad(ID, "params must be an object"))?; + if let Some(k) = patch.keys().find(|k| !matches!(k.as_str(), "layout" | "image" | "page" | "guides" | "job")) { + return Err(bad(ID, format!("unknown parameter `{k}` (one of layout, image, page, guides, job)"))); + } + if let Some(l) = patch.get("layout").and_then(Value::as_object) + && let Some(k) = l.keys().find(|k| matches!(k.as_str(), "cells" | "pages" | "nextId")) + { + return Err(bad(ID, format!("`layout.{k}` is changed with the print.cell.* and print.layout.* commands"))); + } + let mut value = serde_json::to_value(&s.print).map_err(|e| bad(ID, e.to_string()))?; + merge(&mut value, p, 0); + let mut next: PrintSettings = serde_json::from_value(value).map_err(|e| bad(ID, e.to_string()))?; + // A new paper, orientation or margin may leave cells outside the page: pull them back. + if next.layout.page.printable().is_ok() { + next.layout.fit_cells().map_err(|e| bad(ID, e))?; + } + next.validate().map_err(|e| bad(ID, e))?; + s.print = next; + reply(s) +} + +fn reset(s: &mut Session, _: &Value) -> Result { + s.print = PrintSettings::default(); + reply(s) +} + +fn cell_id(p: &Value, id: &str) -> Result { + p.get("id").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(id, "missing cell `id`")) +} + +fn opt_f64(p: &Value, key: &str, id: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().map(Some).ok_or_else(|| bad(id, format!("`{key}` must be a number"))), + } +} + +/// Snaps `r` (a cell being moved or resized) when the call asks for it. +fn snapped(l: &Layout, guides: &crate::print::layout::Guides, id: u32, r: Rect, p: &Value, resize: bool, cmd: &str) -> Result { + let on = match p.get("snap") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`snap` must be true or false")), + }; + if !on || guides.snap == Snap::Off { + return Ok(r); + } + let threshold = opt_f64(p, "threshold", cmd)?.unwrap_or(0.1); + let f = if resize { Layout::snap_resize } else { Layout::snap_move }; + f(l, id, r, guides.snap, guides.grid_size, threshold).map_err(|e| bad(cmd, e)) +} + +fn cell_add(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.add"; + let (w, h) = (f64_req(p, "width", ID)?, f64_req(p, "height", ID)?); + let mut next = s.print.layout.clone(); + let id = next.add_cell(w, h).map_err(|e| bad(ID, e))?; + if let (Some(x), Some(y)) = (opt_f64(p, "x", ID)?, opt_f64(p, "y", ID)?) { + if let Some(page) = p.get("page").and_then(Value::as_u64) { + let page = u32::try_from(page).map_err(|_| bad(ID, "`page` is too large"))?; + next.set_cell_page(id, page).map_err(|e| bad(ID, e))?; + } + next.move_cell(id, x, y).map_err(|e| bad(ID, e))?; + } + if let Some(v) = p.get("photo").filter(|v| !v.is_null()) { + let photo = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id"))?; + next.set_cell_photo(id, Some(photo)).map_err(|e| bad(ID, e))?; + } + s.print.layout = next; + let cell = s.print.layout.cell(id).cloned(); + Ok(json!({"id": id, "cell": cell, "pages": s.print.layout.pages})) +} + +fn cell_move(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.move"; + let id = cell_id(p, ID)?; + let (x, y) = (f64_req(p, "x", ID)?, f64_req(p, "y", ID)?); + let mut next = s.print.layout.clone(); + let size = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?; + let target = snapped(&next, &s.print.guides, id, Rect::new(x, y, size.w, size.h), p, false, ID)?; + let r = next.move_cell(id, target.x, target.y).map_err(|e| bad(ID, e))?; + s.print.layout = next; + Ok(json!({"id": id, "rect": r})) +} + +fn cell_resize(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.resize"; + let id = cell_id(p, ID)?; + let mut next = s.print.layout.clone(); + let cur = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?; + let want = Rect::new( + opt_f64(p, "x", ID)?.unwrap_or(cur.x), + opt_f64(p, "y", ID)?.unwrap_or(cur.y), + opt_f64(p, "width", ID)?.unwrap_or(cur.w), + opt_f64(p, "height", ID)?.unwrap_or(cur.h), + ); + let target = snapped(&next, &s.print.guides, id, want, p, true, ID)?; + let r = next.resize_cell(id, target).map_err(|e| bad(ID, e))?; + s.print.layout = next; + Ok(json!({"id": id, "rect": r})) +} + +fn cell_delete(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.delete"; + let id = cell_id(p, ID)?; + s.print.layout.delete_cell(id).map_err(|e| bad(ID, e))?; + Ok(json!({"deleted": id, "cells": s.print.layout.cells.len()})) +} + +fn cell_stack(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.stack"; + let id = cell_id(p, ID)?; + let how = match p.get("order").and_then(Value::as_str) { + Some("toBack") => Stack::ToBack, + Some("backward") => Stack::Backward, + Some("forward") => Stack::Forward, + Some("toFront") => Stack::ToFront, + _ => return Err(bad(ID, "`order` must be toBack, backward, forward or toFront")), + }; + s.print.layout.restack(id, how).map_err(|e| bad(ID, e))?; + Ok(json!({"order": s.print.layout.cells.iter().map(|c| c.id).collect::>()})) +} + +fn cell_photo(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.photo"; + let id = cell_id(p, ID)?; + let photo = match p.get("photo") { + None | Some(Value::Null) => None, + Some(v) => { + let n = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id or null"))?; + if s.catalog.photo(lightcraft_catalog::PhotoId(n)).is_none() { + return Err(bad(ID, format!("no photo {n}"))); + } + Some(n) + } + }; + s.print.layout.set_cell_photo(id, photo).map_err(|e| bad(ID, e))?; + Ok(json!({"id": id, "photo": photo})) +} + +fn cell_page(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.cell.page"; + let id = cell_id(p, ID)?; + let page = p.get("page").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(ID, "missing zero-based `page`"))?; + s.print.layout.set_cell_page(id, page).map_err(|e| bad(ID, e))?; + Ok(json!({"id": id, "page": page})) +} + +fn auto_layout(s: &mut Session, _: &Value) -> Result { + s.print.layout.auto_layout().map_err(|e| bad("print.layout.autoLayout", e))?; + Ok(json!({"pages": s.print.layout.pages, "cells": s.print.layout.cells})) +} + +fn new_page(s: &mut Session, _: &Value) -> Result { + let page = s.print.layout.new_page().map_err(|e| bad("print.layout.newPage", e))?; + Ok(json!({"page": page, "pages": s.print.layout.pages, "max": MAX_PAGES})) +} + +fn clear(s: &mut Session, _: &Value) -> Result { + s.print.layout.clear(); + Ok(json!({"pages": 1, "cells": 0})) +} + +fn export_pdf(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.exportPdf"; + let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(ID, "missing path"))?; + if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "path" | "ids" | "id"))) { + return Err(bad(ID, format!("unknown parameter `{k}` (one of path, ids; change the print with print.set)"))); + } + if p.get("ids").is_some_and(|v| v.as_array().is_none_or(|a| a.iter().any(|v| v.as_u64().is_none()))) { + return Err(bad(ID, "ids must be an array of photo ids")); + } + if p.get("id").is_some_and(|v| v.as_u64().is_none()) { + return Err(bad(ID, "id must be a photo id")); + } + s.check_write_target(path).map_err(|e| bad(ID, e))?; + let ids = s.targets(p)?; + let settings = s.print.clone(); + // a printer profile that can't be used fails the print before anything renders + crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?; + let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?; + let total = work.len() as u64; + let row = s.activity.start("export", "Printing to PDF", crate::activity::Cancel::No); + row.progress(0, total); + let pages = work + .run(&mut |done, name| { + row.progress(done as u64, total); + row.detail(name); + true + }) + .map_err(|e| bad(ID, e))?; + let bytes = crate::print::output::write(&pages, &settings.job, &title).map_err(|e| bad(ID, e))?; + s.check_write_target(path).map_err(|e| bad(ID, e))?; + crate::export::write_file(path, &bytes).map_err(|e| bad(ID, e))?; + Ok(json!({"path": path, "pages": pages.len(), "photos": ids.len(), "bytes": bytes.len()})) +} + +// ---- templates ------------------------------------------------------------------------------ + +fn template_list(s: &Session) -> Value { + json!({"templates": s.print_templates.iter().map(|t| json!({"name": t.name, "style": t.settings.pointer("/layout/style"), "settings": t.settings})).collect::>()}) +} + +fn template_name(p: &Value, id: &str) -> Result { + clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e)) +} + +fn template_save(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.save"; + let name = template_name(p, ID)?; + let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?; + match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = t, + None => { + if s.print_templates.len() >= MAX_TEMPLATES { + return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first"))); + } + s.print_templates.push(t); + } + } + s.save_prefs()?; + Ok(template_list(s)) +} + +fn template_apply(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.apply"; + let name = template_name(p, ID)?; + let t = s + .print_templates + .iter() + .find(|x| x.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?; + s.print = t.settings(&s.print.guides).map_err(|e| bad(ID, e))?; + reply(s) +} + +fn template_delete(s: &mut Session, p: &Value) -> Result { + const ID: &str = "print.template.delete"; + let name = template_name(p, ID)?; + let before = s.print_templates.len(); + s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name)); + if s.print_templates.len() == before { + return Err(bad(ID, format!("no print template `{name}`"))); + } + s.save_prefs()?; + Ok(template_list(s)) +} + +pub fn specs() -> Vec { + vec![ + cmd!(query "print.get", "Print Settings", [], None, + "{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches", + always, get), + cmd!(query "print.set", "Set Print Settings", [], None, + "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", + always, set), + cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset), + cmd!(query "print.cell.add", "Add Print Cell", [], None, + "{width, height, x?, y?, page?, photo?} inches; picture/custom package → {id, cell, pages}; the cell goes to the first free spot unless x and y are given", + always, cell_add), + cmd!(query "print.cell.move", "Move Print Cell", [], None, "{id, x, y, snap?: bool, threshold?: inches} → {id, rect}; snap uses the guides' snap mode", always, cell_move), + cmd!(query "print.cell.resize", "Resize Print Cell", [], None, + "{id, width?, height?, x?, y?, snap?: bool, threshold?} → {id, rect}", always, cell_resize), + cmd!(query "print.cell.delete", "Delete Print Cell", [], None, "{id}", always, cell_delete), + cmd!(query "print.cell.stack", "Arrange Print Cell", [], None, "{id, order: toBack|backward|forward|toFront} → {order: cell ids back to front}", always, cell_stack), + cmd!(query "print.cell.photo", "Set Print Cell Photo", [], None, "{id, photo: photo id | null} (custom package: the photo printed in the cell)", always, cell_photo), + cmd!(query "print.cell.page", "Move Print Cell to Page", [], None, "{id, page: zero-based}", always, cell_page), + cmd!(query "print.layout.autoLayout", "Auto Layout", [], None, "{} — packs the package's cells onto as few pages as possible → {pages, cells}", always, auto_layout), + cmd!(query "print.layout.newPage", "New Print Page", [], None, "{} → {page, pages}", always, new_page), + cmd!(query "print.layout.clear", "Clear Print Layout", [], None, "{} — removes every cell and extra page", always, clear), + cmd!(query "print.exportPdf", "Print to PDF", [], None, + "{path, ids?: photo ids (default selection)} → {path, pages, photos, bytes}; prints the current print.get settings; replaces an existing output, never an original", + always, export_pdf), + cmd!(query "print.template.list", "Print Templates", [], None, "{} → {templates: [{name, style, settings}]}", always, |s, _| Ok(template_list(s))), + cmd!(query "print.template.save", "Save Print Template", [], None, + "{name} — saves the layout, image settings, page and job (not the cells' photos or the guides); an existing name (any case) is replaced → print.template.list", + always, template_save), + cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply), + cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete), + ] +} diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index 84987b15b..fb0ec9883 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -12,7 +12,7 @@ fn save(s: &mut Session, p: &Value) -> Result { let mut merged = Vec::new(); let mut backups = Vec::new(); let mut failed = Vec::new(); - let ids = s.targets(p); + let ids = s.targets(p)?; let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids); for id in ids { match s.save_sidecar_with(id, &owners) { @@ -37,7 +37,7 @@ fn read(s: &mut Session, p: &Value) -> Result { let mut read = Vec::new(); let mut failed = Vec::new(); let mut warnings = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { match s.read_sidecar_op_with_warnings(id) { Ok(Some((op, from, mut photo_warnings))) => { ops.push(op); diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index 4dd440265..381e84218 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -83,7 +83,7 @@ pub fn prepare(session: &mut Session, p: &Value) -> Result { if p.get("id").is_some_and(|v| v.as_u64().is_none()) { return Err("id must be a photo id".into()); } - let ids = session.targets(p); + let ids = session.targets(p).map_err(|e| e.to_string())?; if ids.is_empty() || ids.len() > 1000 { return Err("select between 1 and 1000 photos".into()); } diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index b9e414d51..d294ed4d9 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -185,6 +185,17 @@ impl DestinationMode { } } +impl Conflict { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "unique" => Some(Self::Unique), + "overwrite" => Some(Self::Overwrite), + "skip" => Some(Self::Skip), + _ => None, + } + } +} + /// A named set of `app.export` params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportPreset { @@ -214,6 +225,18 @@ pub enum SharpenFor { Glossy, } +impl SharpenFor { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "none" => Some(Self::None), + "screen" => Some(Self::Screen), + "matte" => Some(Self::Matte), + "glossy" => Some(Self::Glossy), + _ => None, + } + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SharpenAmount { @@ -223,6 +246,17 @@ pub enum SharpenAmount { High, } +impl SharpenAmount { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "low" => Some(Self::Low), + "standard" => Some(Self::Standard), + "high" => Some(Self::High), + _ => None, + } + } +} + /// Where a watermark sits. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -537,6 +571,18 @@ pub enum MetadataPolicy { None, } +impl MetadataPolicy { + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "all" => Some(Self::All), + "allexceptcamera" => Some(Self::AllExceptCamera), + "copyright" => Some(Self::Copyright), + "none" => Some(Self::None), + _ => None, + } + } +} + /// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or /// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -814,6 +860,10 @@ impl ExportOptions { let s = string(cmd, k, v)?; if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } }; + // a choice `from_json` reads with the same `parse`, so what passes here is what gets exported + let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> { + if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } + }; for (k, v) in obj { if v.is_null() { continue; @@ -869,10 +919,10 @@ impl ExportOptions { "naming" | "subfolder" | "path" | "dir" | "preset" => { string(cmd, k, v)?; } - "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, - "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, + "sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?, + "sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?, "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, - "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, + "conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?, "destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, @@ -880,7 +930,7 @@ impl ExportOptions { serde_json::from_value::(v.clone()) .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?; } - "metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?, + "metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?, "colorSpace" => { if OutputSpace::parse(string(cmd, k, v)?).is_none() { return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020"))); @@ -982,13 +1032,13 @@ impl ExportOptions { resize: Self::resize_from_json(p), ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), - sharpen: enm(p, "sharpen").unwrap_or(d.sharpen), - sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), + sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen), + sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount), naming: s("naming").map_or(d.naming, str::to_string), start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode), subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), - conflict: enm(p, "conflict").unwrap_or(d.conflict), + conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict), tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { Some("none") => TiffCompression::None, Some("lzw") => TiffCompression::Lzw, @@ -996,7 +1046,7 @@ impl ExportOptions { _ => d.tiff_compression, }, dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), - metadata: enm(p, "metadata").unwrap_or(d.metadata), + metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata), remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), watermark: match p.get("watermark") { Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), @@ -2457,7 +2507,7 @@ mod tests { fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { use ab_glyph::Font; let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { - eprintln!("skipped: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); return; }; let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); @@ -2529,7 +2579,7 @@ mod tests { compare(composed, decomposed, crate::fonts::CRAFT_FONTS); } } else { - eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); } } @@ -2553,7 +2603,7 @@ mod tests { use ab_glyph::Font; let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); if fonts.len() < 2 { - eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); return; } for c in "日本語の文字".chars() { @@ -3182,6 +3232,58 @@ mod tests { assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o); } + /// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the + /// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and + /// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and + /// `conflict` had the same gap. + #[test] + fn accepted_choices_are_the_ones_exported() { + use serde_json::json; + let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string()); + let metadata = [ + ("all", MetadataPolicy::All), + ("allExceptCamera", MetadataPolicy::AllExceptCamera), + ("copyright", MetadataPolicy::Copyright), + ("none", MetadataPolicy::None), + ]; + for (name, want) in metadata { + for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] { + assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); + } + } + for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] + { + assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); + } + for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { + assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want); + } + for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] { + assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want); + } + // an explicit value nothing reads as is an error, never the permissive default + for v in ["", " none", "nothing", "all-except-camera"] { + let e = read("metadata", v).unwrap_err(); + assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}"); + } + assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high")); + assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip")); + // absent (or null) keeps the defaults + let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap(); + assert_eq!( + (d.metadata, d.sharpen, d.sharpen_amount, d.conflict), + (MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique) + ); + // `removeLocation` stays independent of the policy's spelling + let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap(); + assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true)); + // stored settings (lenient `from_json`) read the same spellings + assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None); + // and `to_json` still writes what the MCP schema lists + let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json(); + assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip"))); + } + /// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in /// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. #[test] @@ -3264,7 +3366,7 @@ mod tests { #[test] fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } let mut s = crate::Session::with_demo(); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 2c3c85341..6b1cc2dfb 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -159,10 +159,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { } Err(Err(e)) => return Err(e), }; - let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32); - if w <= 1 || h <= 1 { - (w, h) = (raw.active_area.width as u32, raw.active_area.height as u32); - } + let (w, h) = raw.developed_size(); + let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } @@ -267,19 +265,43 @@ impl CameraColourModel<'_> { /// White balance, matrix, tables and baseline exposure, in place. pub fn apply(&self, img: &mut Rgb32f) { let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain); - img.map_in_place(|p| { - let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let rgb = [ - m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], - m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], - m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], - ]; - let rgb = match self.tables { - Some(tables) => tables.apply(rgb, gain), - None => rgb.map(|v| v * gain), - }; - self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) - }); + if let (None, Some(hue_sat)) = (self.tables, self.hue_sat) { + // a fitted camera look: `pixel`, `LANES` pixels at a time so the table step vectorises (same bits) + const LANES: usize = 16; + lightcraft_raster::par_rows(&mut img.data, img.width, |_, row| { + for chunk in row.chunks_mut(LANES) { + let mut rgb = [[0.0f32; LANES]; 3]; + for (l, p) in chunk.iter().enumerate() { + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + for (i, rgb) in rgb.iter_mut().enumerate() { + rgb[l] = (m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) * gain; + } + } + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter_mut().enumerate() { + *p = [out[0][l], out[1][l], out[2][l]].map(|v| v.max(0.0)); + } + } + }); + return; + } + img.map_in_place(|p| self.pixel(&m, p)); + } + + /// [`Self::apply`] to one pixel; `m` is the matrix as `f32`. + fn pixel(&self, m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] { + let (wb, gain) = (self.wb, self.gain); + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + let rgb = [ + m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], + m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], + m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], + ]; + let rgb = match self.tables { + Some(tables) => tables.apply(rgb, gain), + None => rgb.map(|v| v * gain), + }; + self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) } } @@ -408,8 +430,10 @@ fn load_bytes_now( // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = - rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref()), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = rayon::join( + || crate::camera_preview::starting_colour(&mut raw, &bytes, lens.as_ref(), lens_geometry.as_ref()), + || dng_mattes(&bytes, &info), + ); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -479,10 +503,11 @@ fn load_bytes_now( // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| { - let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; - Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) - }); + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) + .then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) + }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let local_tone = local_tone(&raw); let (temp, tint) = scale.as_shot(xy); @@ -1643,7 +1668,7 @@ pub(crate) mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); @@ -1810,6 +1835,31 @@ pub(crate) mod tests { lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) } + /// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the + /// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. + #[test] + fn camera_look_in_lanes_matches_pixel_by_pixel() { + let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); + let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; + let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); + let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; + let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; + let img = Rgb32f::from_fn(37, 5, |x, y| { + let k = y * 37 + x; + if k % 5 == 0 { + [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] + } else { + [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] + } + }); + let mut lanes = img.clone(); + colour.apply(&mut lanes); + let m = colour.matrix.to_f32(); + for (p, got) in img.data.iter().zip(&lanes.data) { + assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); + } + } + #[test] fn clipped_white_renders_neutral_through_the_colour_model() { use lightcraft_color::{LUMA_2020, Mat3}; @@ -1994,7 +2044,7 @@ pub(crate) mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let (img, _) = load_bytes(&bytes, 1600).unwrap(); diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 1894f7189..6338b3b40 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -64,8 +64,7 @@ fn frame_crop_factor(camera: Option, focal: f64, focal_35mm: Option) - pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option { crate::files::embedded_lens(raw).or_else(|| { let db = database()?; - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) }; + let (w, h) = raw.developed_size(); lookup(db, &meta(), w as f64, h as f64, raw.orientation) }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7df97e837..18a8c5fb2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -59,6 +59,10 @@ mod pdf; pub mod preset_import; pub mod preset_luminar; pub mod presets; +pub mod print; +pub mod print_color; +pub mod print_pdf; +pub mod profile_refs; pub mod proof; pub mod rename; pub mod segment; @@ -291,6 +295,11 @@ pub struct Session { pub last_export: Option, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec, + /// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives + /// with the session, so the app, CLI, control channel and MCP all see the same print. + pub print: print::template::PrintSettings, + /// Saved print templates (`print.template.*`), persisted in prefs.json. + pub print_templates: Vec, /// Metadata presets (`metadata.*`), persisted in prefs.json. pub metadata_presets: Vec, /// Saved filter-bar settings (`filter.*`), persisted in prefs.json. @@ -444,6 +453,8 @@ impl Session { xmp: sidecar::XmpPrefs::default(), last_export: None, export_presets: Vec::new(), + print: Default::default(), + print_templates: Vec::new(), metadata_presets: Vec::new(), filter_presets: Vec::new(), curve_presets: Vec::new(), @@ -500,7 +511,11 @@ impl Session { } let empty = Value::Object(Default::default()); let params = if params.is_null() { &empty } else { params }; - self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)) + self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)).map_err(|e| match e { + // a shared parameter check ([`Session::targets`]) names the command it ran for + EngineError::BadParams { cmd, msg } if cmd.is_empty() => EngineError::BadParams { cmd: id.to_string(), msg }, + e => e, + }) } /// Run `f` as command `id` with what [`Session::execute`] does around every command: the @@ -1027,8 +1042,12 @@ impl Session { && !al.is_smart() && self.filter == Filter::default() { - let order = al.photos.clone(); - visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX)); + // each photo's place looked up, not searched for (a big album sorted in O(n log n)) + let mut place: std::collections::HashMap = std::collections::HashMap::with_capacity(al.photos.len()); + for (i, id) in al.photos.iter().enumerate() { + place.entry(*id).or_insert(i); + } + visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX)); if !self.sort.ascending { visible.reverse(); } @@ -1167,14 +1186,24 @@ impl Session { (self.visible_gen, self.visible.clone()) } - /// Targets of photo commands: explicit `ids`/`id` param, else the selection. - pub fn targets(&self, p: &Value) -> Vec { - if let Some(a) = p.get("ids").and_then(Value::as_array) { - return a.iter().filter_map(Value::as_u64).map(PhotoId).collect(); + /// Targets of photo commands: the explicit `ids` (photo ids) or `id` (a photo id) param, else [`Self::selected`]. + /// An `ids` or `id` that is there but isn't photo ids is an error, never the selection or part of the list + /// (issue #1506): a mistyped target mustn't turn a command meant for one photo into one for every selected photo. + pub fn targets(&self, p: &Value) -> Result> { + let invalid = |what: &str| EngineError::BadParams { cmd: String::new(), msg: format!("`{what}` must be photo ids (whole numbers)") }; + match p.get("ids") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) => return a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| invalid("ids"))).collect(), + Some(_) => return Err(invalid("ids")), } - if let Some(id) = p.get("id").and_then(Value::as_u64) { - return vec![PhotoId(id)]; + match p.get("id") { + None | Some(Value::Null) => Ok(self.selected()), + Some(v) => v.as_u64().map(|id| vec![PhotoId(id)]).ok_or_else(|| invalid("id")), } + } + + /// The photos commands act on when a call names none: the selection, else the active photo. + pub fn selected(&self) -> Vec { if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() } } } @@ -1254,6 +1283,8 @@ mod tests_prefs; #[cfg(test)] mod tests_presets_scale; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_process; #[cfg(test)] mod tests_recheck; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 74a1b3985..51655aea5 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -209,6 +209,8 @@ struct PrefsFile { last_export: Option, /// The user's export presets. export_presets: Vec, + /// Print templates. + print_templates: Vec, /// Metadata presets. metadata_presets: Vec, /// Filter presets. @@ -491,6 +493,7 @@ impl Session { self.xmp = prefs.xmp; self.last_export = prefs.last_export; self.export_presets = prefs.export_presets; + self.print_templates = prefs.print_templates; self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; self.curve_presets = prefs.curve_presets; @@ -744,6 +747,7 @@ impl Session { xmp: self.xmp, last_export: self.last_export.clone(), export_presets: self.export_presets.clone(), + print_templates: self.print_templates.clone(), metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), curve_presets: self.curve_presets.clone(), diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 50983c86c..6d1bfe0de 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 63; +pub const RENDER_CACHE_VERSION: u64 = 64; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ed61c0509..26c69c63d 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -191,6 +191,7 @@ impl MergeJob { metadata: r.metadata, baseline_exposure: r.baseline_exposure, lens: Vec::new(), + wb_multipliers: r.wb_multipliers, }; f.orient(); merged.push(f); diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 3b36316fb..f1d787ae0 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -395,6 +395,20 @@ pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_ma let mut values = crate::crs_masks::Values::new(); for (k, v) in fields { let key = format!("crs:{k}"); + // the profile a preset names: its fields as XMP's struct fields (`crs:Look/crs:Name`…) + if k == "Look" + && let Lua::Table(_, look) = v + { + for (f, fv) in look { + let val = match fv { + Lua::Num(n) => format!("{n}"), + Lua::Str(s) => delocalize(s), + _ => continue, + }; + props.insert(format!("crs:Look/crs:{f}"), vec![val]); + } + continue; + } if crate::crs_masks::CONTAINERS.contains(&key.as_str()) { values.insert(key.clone(), crate::crs_masks::from_lua(v)); } @@ -497,6 +511,12 @@ pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: Stri o.remove("wb"); } } + // the profile it names, resolved against the user's imported profiles by `preset.import` + if let Some(look) = crate::profile_refs::look_ref(props) + && let Some(o) = settings.as_object_mut() + { + o.insert("profile".into(), serde_json::json!({"look": look})); + } if settings.as_object().is_none_or(Map::is_empty) { return None; } @@ -684,6 +704,17 @@ text]], z = ZSTR "loc" }"#, assert_eq!(d.light.exposure, 0.35); } + #[test] + fn lrtemplate_keeps_the_profile_it_names() { + let t = r#"s = { title = "Film", value = { settings = { Exposure2012 = 0.2, + Look = { Name = "Portra 400", Amount = 0.5, UUID = "ABCD", Group = "$$$/Pack=Film Pack", Parameters = { Version = "14.0" } } } } }"#; + let v = read_presets("Film.lrtemplate", t.as_bytes(), None).unwrap(); + assert_eq!( + v[0].preset.settings["profile"], + serde_json::json!({"look": {"name": "Portra 400", "amount": 50.0, "group": "Film Pack", "uuid": "abcd"}}) + ); + } + #[test] fn old_process_version_fields_are_approximated() { let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }"; diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs new file mode 100644 index 000000000..91e5db69e --- /dev/null +++ b/crates/engine/src/print/compose.rs @@ -0,0 +1,856 @@ +//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit), +//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s. +//! +//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one +//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a +//! background thread with progress and cancellation. +//! +//! Photos are rendered with the normal export pipeline into Rec. 2020 at 16 bits, at exactly the +//! pixel size the cell needs at the job's resolution; colours given in sRGB (background, border, +//! stroke) are converted to the same encoding. +use super::layout::{Layout, Rect, Style}; +use super::template::PrintSettings; +use super::{MAX_JOB_PAGES, MAX_JOB_PIXELS, MAX_PAGE_PIXELS, PageRaster}; +use crate::Session; +use crate::export::{ExportOptions, output_size}; +use crate::media::RenderJob; +use lightcraft_catalog::PhotoId; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_pipeline::{DeepSamples, OutputDepth, OutputSpace}; +use serde::{Deserialize, Serialize}; + +/// Most photos one print job takes. +pub const MAX_PHOTOS: usize = 1000; +/// Widest Photo Border, inches. +pub const MAX_BORDER_IN: f64 = 2.0; +/// Widest Inner Stroke, points. +pub const MAX_STROKE_PT: f64 = 36.0; +/// Renders kept for cells that print the same photo at the same size. +const RENDER_CACHE: usize = 4; + +/// Lightroom's Image Settings panel. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct ImageSettings { + /// Fill each cell with the photo and crop what overflows (centred); off: fit the whole photo. + pub zoom_to_fill: bool, + /// Turn a photo a quarter when that makes it print larger (fit) or crop less (fill). + pub rotate_to_fit: bool, + /// A border around the photo inside its cell; Picture Package and Custom Package only. + pub border_enabled: bool, + /// Border width, inches. + pub border_width: f64, + /// Border colour, sRGB. + pub border_color: [u8; 3], + /// A stroke drawn inside the photo's edge, every layout style. + pub stroke_enabled: bool, + /// Stroke width, points. + pub stroke_width_pt: f64, + /// Stroke colour, sRGB. + pub stroke_color: [u8; 3], +} + +impl Default for ImageSettings { + fn default() -> Self { + Self { + zoom_to_fill: false, + rotate_to_fit: false, + border_enabled: false, + border_width: 0.25, + border_color: [255; 3], + stroke_enabled: false, + stroke_width_pt: 1.0, + stroke_color: [0; 3], + } + } +} + +impl ImageSettings { + pub fn validate(&self) -> Result<(), String> { + let ok = |v: f64, max: f64| v.is_finite() && (0.0..=max).contains(&v); + if !ok(self.border_width, MAX_BORDER_IN) { + return Err(format!("border width must be 0 to {MAX_BORDER_IN} in")); + } + if !ok(self.stroke_width_pt, MAX_STROKE_PT) { + return Err(format!("stroke width must be 0 to {MAX_STROKE_PT} pt")); + } + Ok(()) + } +} + +/// Lightroom's Page panel (the parts that are not margins or overlays). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PageStyle { + /// Page background colour, sRGB. + pub background: [u8; 3], +} + +impl Default for PageStyle { + fn default() -> Self { + Self { background: [255; 3] } + } +} + +/// An sRGB colour as 16-bit Rec. 2020 samples (the encoding of [`PageRaster`]). +pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] { + let lin = SRGB.to_space(&REC2020).apply_f32(c.map(|v| srgb_to_linear(f32::from(v) / 255.0))); + let trc = OutputSpace::Rec2020.trc(); + lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16) +} + +// ---- placement ------------------------------------------------------------------------------ + +/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + /// Turned a quarter (clockwise). + pub rotated: bool, + /// Scaled size after turning: at most the area when fitting, at least the area when filling. + pub w: usize, + pub h: usize, +} + +impl Placement { + /// The scaled size before turning (the size to render at). + pub fn unturned(&self) -> (usize, usize) { + if self.rotated { (self.h, self.w) } else { (self.w, self.h) } + } +} + +/// Decides turning and scale. `None` for an empty area or a photo without size. +pub fn place(iw: f64, ih: f64, aw: usize, ah: usize, fill: bool, rotate: bool) -> Option { + if !(iw.is_finite() && ih.is_finite() && iw >= 1.0 && ih >= 1.0) || aw == 0 || ah == 0 { + return None; + } + let (af, bf) = (aw as f64, ah as f64); + let scale = |w: f64, h: f64| if fill { (af / w).max(bf / h) } else { (af / w).min(bf / h) }; + let (s0, s1) = (scale(iw, ih), scale(ih, iw)); + let rotated = rotate && if fill { s1 < s0 * (1.0 - 1e-9) } else { s1 > s0 * (1.0 + 1e-9) }; + let (w, h) = if rotated { (ih * s1, iw * s1) } else { (iw * s0, ih * s0) }; + let (w, h) = if fill { + (((w - 1e-6).ceil() as usize).max(aw), ((h - 1e-6).ceil() as usize).max(ah)) + } else { + ((w.round() as usize).clamp(1, aw), (h.round() as usize).clamp(1, ah)) + }; + Some(Placement { rotated, w, h }) +} + +/// A rectangle in page pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PxRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + +/// The pixel rectangle of an inch rectangle on a `pw` x `ph` page at `ppi`, clipped to the page. +pub fn px_rect(r: &Rect, ppi: f64, pw: usize, ph: usize) -> Option { + if !r.is_finite() { + return None; + } + let edge = |v: f64, max: usize| (v * ppi).round().clamp(0.0, max as f64) as usize; + let (x0, y0, x1, y1) = (edge(r.x, pw), edge(r.y, ph), edge(r.right(), pw), edge(r.bottom(), ph)); + (x1 > x0 && y1 > y0).then_some(PxRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }) +} + +/// The cells of every page with the photo each prints, by layout style: Single Image and Contact +/// Sheet lay `photos` out in sequence over as many pages as needed; Picture Package prints the +/// first photo in every cell; Custom Package prints each cell's own photo (else the first). +pub fn plan_cells(layout: &Layout, photos: &[PhotoId]) -> Result>, String> { + let first = photos.first().copied().ok_or("select at least one photo to print")?; + if photos.len() > MAX_PHOTOS { + return Err(format!("a print takes at most {MAX_PHOTOS} photos")); + } + match layout.style { + Style::SingleImage | Style::ContactSheet => { + let grid = layout.grid_rects()?; + let per_page = grid.len().max(1); + let pages = photos.len().div_ceil(per_page); + if pages > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages; use a bigger grid or fewer photos")); + } + Ok(photos.chunks(per_page).map(|chunk| chunk.iter().zip(&grid).map(|(id, r)| (*r, *id)).collect()).collect()) + } + Style::PicturePackage | Style::CustomPackage => { + let pages = layout.pages.max(1) as usize; + if pages > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); + } + if layout.cells.is_empty() { + return Err("the layout has no cells; add some (print.cell.add) or use Auto Layout".into()); + } + let mut out: Vec> = vec![Vec::new(); pages]; + for c in &layout.cells { + let photo = match layout.style { + Style::CustomPackage => c.photo.map_or(first, PhotoId), + _ => first, + }; + out.get_mut(c.page as usize).ok_or("a cell is on a page the layout does not have")?.push((c.rect, photo)); + } + Ok(out) + } + } +} + +// ---- pixels ---------------------------------------------------------------------------------- + +/// 16-bit interleaved RGB. +#[derive(Clone, Debug, PartialEq)] +pub struct Image16 { + pub w: usize, + pub h: usize, + pub rgb: Vec, +} + +impl Image16 { + pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result { + let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?; + let mut rgb = Vec::new(); + rgb.try_reserve_exact(n).map_err(|_| "not enough memory for the page")?; + for _ in 0..w * h { + rgb.extend_from_slice(&c); + } + Ok(Self { w, h, rgb }) + } + + fn px(&self, x: usize, y: usize) -> Option<[u16; 3]> { + let i = y.checked_mul(self.w)?.checked_add(x)?.checked_mul(3)?; + let s = self.rgb.get(i..i.checked_add(3)?)?; + Some([*s.first()?, *s.get(1)?, *s.get(2)?]) + } + + /// Fills a rectangle (clipped to the image). + pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, c: [u16; 3]) { + let x1 = x.saturating_add(w).min(self.w); + for row in y..y.saturating_add(h).min(self.h) { + let Some(line) = self.rgb.get_mut((row * self.w + x.min(x1)) * 3..(row * self.w + x1) * 3) else { continue }; + for px in line.as_chunks_mut::<3>().0 { + *px = c; + } + } + } + + /// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images. + pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) { + let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); + let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); + for row in 0..h { + let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3); + let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3); + if let (Some(from), Some(to)) = (from, to) { + to.copy_from_slice(from); + } + } + } + + /// A quarter turn clockwise. + pub fn rotated_cw(&self) -> Result { + let mut out = Image16::filled(self.h, self.w, [0; 3])?; + for y in 0..self.h { + for x in 0..self.w { + let Some(p) = self.px(x, y) else { continue }; + // (x, y) lands at (h - 1 - y, x). + let i = (x * out.w + (self.h - 1 - y)) * 3; + if let Some(d) = out.rgb.get_mut(i..i + 3) { + d.copy_from_slice(&p); + } + } + } + Ok(out) + } + + /// Scales to `nw` x `nh`: area-weighted when shrinking an axis, linear when growing it. + pub fn resized(&self, nw: usize, nh: usize) -> Result { + if nw == 0 || nh == 0 || self.w == 0 || self.h == 0 { + return Err("empty image".into()); + } + if (nw, nh) == (self.w, self.h) { + return Ok(self.clone()); + } + let wx = axis_weights(self.w, nw); + let wy = axis_weights(self.h, nh); + // Horizontal pass into 16-bit rows, then the vertical pass. + let mut mid = Image16::filled(nw, self.h, [0; 3])?; + for y in 0..self.h { + let Some(src) = self.rgb.get(y * self.w * 3..(y + 1) * self.w * 3) else { continue }; + let Some(dst) = mid.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue }; + for (x, taps) in wx.iter().enumerate() { + for c in 0..3 { + let v: f32 = taps.iter().map(|&(i, k)| src.get(i * 3 + c).map_or(0.0, |s| f32::from(*s) * k)).sum(); + if let Some(d) = dst.get_mut(x * 3 + c) { + *d = v.round().clamp(0.0, 65535.0) as u16; + } + } + } + } + let mut out = Image16::filled(nw, nh, [0; 3])?; + for (y, taps) in wy.iter().enumerate() { + let Some(dst) = out.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue }; + for (i, d) in dst.iter_mut().enumerate() { + let v: f32 = taps.iter().map(|&(row, k)| mid.rgb.get(row * nw * 3 + i).map_or(0.0, |s| f32::from(*s) * k)).sum(); + *d = v.round().clamp(0.0, 65535.0) as u16; + } + } + Ok(out) + } +} + +/// For each of `new` output samples, the `(source index, weight)` taps that make it (weights sum to 1). +fn axis_weights(old: usize, new: usize) -> Vec> { + let (o, n) = (old as f64, new as f64); + (0..new) + .map(|i| { + if new < old { + let step = o / n; + let (a, b) = (i as f64 * step, (i as f64 + 1.0) * step); + let first = a.floor() as usize; + let last = (b.ceil() as usize).min(old); + (first..last) + .filter_map(|j| { + let overlap = (b.min(j as f64 + 1.0) - a.max(j as f64)).max(0.0); + (overlap > 0.0).then_some((j, (overlap / step) as f32)) + }) + .collect() + } else { + let pos = (i as f64 + 0.5) * o / n - 0.5; + let j0 = pos.floor(); + let f = (pos - j0) as f32; + let at = |j: f64| j.clamp(0.0, o - 1.0) as usize; + vec![(at(j0), 1.0 - f), (at(j0 + 1.0), f)] + } + }) + .collect() +} + +/// Everything about a cell's look that does not depend on the photo. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CellStyle { + pub fill: bool, + pub rotate: bool, + pub border_px: usize, + pub border: [u16; 3], + pub stroke_px: usize, + pub stroke: [u16; 3], +} + +impl CellStyle { + /// The style for `settings` at `ppi`; the border applies to the package styles only. + pub fn of(settings: &PrintSettings, ppi: f64) -> Self { + let img = &settings.image; + let border_px = if img.border_enabled && settings.layout.style.is_package() { (img.border_width * ppi).round() as usize } else { 0 }; + let stroke_px = + if img.stroke_enabled && img.stroke_width_pt > 0.0 { ((img.stroke_width_pt / 72.0 * ppi).round() as usize).max(1) } else { 0 }; + Self { + fill: img.zoom_to_fill, + rotate: img.rotate_to_fit, + border_px, + border: rec2020_from_srgb8(img.border_color), + stroke_px, + stroke: rec2020_from_srgb8(img.stroke_color), + } + } +} + +/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when +/// asked), then the inner stroke on top of it. +pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> { + // The border may not eat the cell: at least one pixel of photo area stays. + let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); + let (ax, ay) = (cell.x + border, cell.y + border); + let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border); + let p = place(photo.w as f64, photo.h as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?; + let (uw, uh) = p.unturned(); + let mut img = photo.resized(uw, uh)?; + if p.rotated { + img = img.rotated_cw()?; + } + // Where the photo lands: centred in the area, cropped to it when it overflows. + let (sx, sy) = (img.w.saturating_sub(aw) / 2, img.h.saturating_sub(ah) / 2); + let (w, h) = (img.w.min(aw), img.h.min(ah)); + let (dx, dy) = (ax + (aw - w) / 2, ay + (ah - h) / 2); + if border > 0 { + page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border); + } + page.blit(&img, sx, sy, dx, dy, w, h); + let s = style.stroke_px.min(w.min(h) / 2); + if s > 0 { + page.fill_rect(dx, dy, w, s, style.stroke); + page.fill_rect(dx, dy + h - s, w, s, style.stroke); + page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke); + page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke); + } + Ok(()) +} + +// ---- the job --------------------------------------------------------------------------------- + +struct Item { + page: usize, + name: String, + cell: PxRect, + key: (u64, usize, usize), + job: RenderJob, +} + +/// A print job set up from the session: one render per cell, ready to run on another thread. +pub struct Prepared { + pages: Vec<(usize, usize)>, + items: Vec, + ppi: f32, + background: [u16; 3], + style: CellStyle, + /// Print sharpening, applied to each photo once it's rendered at its print size. + color: crate::print_color::PrintColorSettings, +} + +impl Prepared { + /// Renders (progress steps) the job will run. + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Pages the job will produce. + pub fn pages(&self) -> usize { + self.pages.len() + } + + /// Renders every photo and composes the pages. `progress(done, name)` is called before each + /// render with the photo's file name; returning `false` cancels (no partial result). + pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result, String> { + let mut cache: Vec<((u64, usize, usize), Image16)> = Vec::new(); + let mut done = 0usize; + let mut items = self.items.into_iter().peekable(); + let mut out = Vec::with_capacity(self.pages.len()); + for (index, &(w, h)) in self.pages.iter().enumerate() { + let mut page = Image16::filled(w, h, self.background)?; + while let Some(item) = items.next_if(|i| i.page == index) { + if !progress(done, &item.name) { + return Err("Print cancelled".into()); + } + done += 1; + let at = cache.iter().position(|(k, _)| *k == item.key); + let at = match at { + Some(at) => at, + None => { + let img = render(item.job, &self.color).map_err(|e| format!("{}: {e}", item.name))?; + if cache.len() >= RENDER_CACHE { + cache.remove(0); + } + cache.push((item.key, img)); + cache.len() - 1 + } + }; + let photo = &cache.get(at).ok_or("render cache lost an entry")?.1; + draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?; + } + out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb }); + } + if !progress(done, "") { + return Err("Print cancelled".into()); + } + Ok(out) + } +} + +fn render(job: RenderJob, color: &crate::print_color::PrintColorSettings) -> Result { + let rendered = job.run().rendered?; + let mut deep = rendered.deep.ok_or("the render has no 16-bit samples")?; + // print sharpening for the paper, at the size the photo prints + color.sharpen_photo(&mut deep); + let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) }; + if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) { + return Err("the render is not Rec. 2020 RGB".into()); + } + Ok(Image16 { w: deep.width, h: deep.height, rgb }) +} + +/// Sets a print job up: the cells for `photos` under `settings.layout`, one render job per cell. +pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoId]) -> Result { + let mut settings = settings.clone(); + settings.validate()?; + let ppi = f64::from(settings.job.effective_ppi()); + let (pw_in, ph_in) = settings.layout.page_size()?; + let (pw, ph) = ((pw_in * ppi).round() as usize, (ph_in * ppi).round() as usize); + let page_px = (pw as u64).saturating_mul(ph as u64); + if pw == 0 || ph == 0 || page_px > MAX_PAGE_PIXELS { + return Err(format!("a page of {pw} x {ph} pixels is too large; lower the print resolution")); + } + let plan = plan_cells(&settings.layout, photos)?; + if page_px.saturating_mul(plan.len() as u64) > MAX_JOB_PIXELS { + return Err("the print is too large for memory; lower the print resolution or print fewer pages".into()); + } + let style = CellStyle::of(&settings, ppi); + let mut items = Vec::new(); + for (page, cells) in plan.iter().enumerate() { + for (rect, id) in cells { + let Some(cell) = px_rect(rect, ppi, pw, ph) else { continue }; + let photo = session.catalog.photo(*id).ok_or_else(|| format!("no photo {}", id.0))?; + let name = photo.file_name.clone(); + let (nw, nh) = output_size(photo, &ExportOptions::default()); + let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); + let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border); + let p = place(nw as f64, nh as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?; + let (rw, rh) = p.unturned(); + let job = session.export_job(*id, rw, rh, OutputSpace::Rec2020, OutputDepth::U16)?; + items.push(Item { page, name, cell, key: (id.0, rw, rh), job }); + } + } + Ok(Prepared { + pages: vec![(pw, ph); plan.len()], + items, + ppi: ppi as f32, + background: rec2020_from_srgb8(settings.page.background), + style, + color: settings.job.color.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::layout::{GridSpec, Margins}; + + const RED: [u16; 3] = [60000, 1000, 1000]; + const BLUE: [u16; 3] = [1000, 1000, 60000]; + const WHITE: [u16; 3] = [65535; 3]; + + /// A `w` x `h` image whose left half is red and right half blue, with a green top row. + fn halves(w: usize, h: usize) -> Image16 { + let mut img = Image16::filled(w, h, RED).unwrap(); + img.fill_rect(w / 2, 0, w - w / 2, h, BLUE); + img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]); + img + } + + fn style() -> CellStyle { + CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] } + } + + fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] { + img.px(x, y).unwrap() + } + + fn page() -> Image16 { + Image16::filled(100, 100, WHITE).unwrap() + } + + #[test] + fn placement_fit_fill_and_rotation() { + // 4:3 photo into a 2:1 area. + let fit = place(400.0, 300.0, 200, 100, false, false).unwrap(); + assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false)); + let fill = place(400.0, 300.0, 200, 100, true, false).unwrap(); + assert_eq!((fill.w, fill.h), (200, 150)); + // A portrait photo in a landscape area: turning prints it larger. + assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 }); + let turned = place(300.0, 400.0, 400, 300, false, true).unwrap(); + assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300)); + assert_eq!(turned.unturned(), (300, 400)); + // Filling a landscape cell with a portrait photo crops less when turned. + let f = place(300.0, 400.0, 400, 300, true, true).unwrap(); + assert!(f.rotated && f.w >= 400 && f.h >= 300); + // Matching orientation never turns. + assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated); + assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated); + // Hostile input. + for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] { + assert!(place(bad, 10.0, 10, 10, false, false).is_none()); + assert!(place(10.0, bad, 10, 10, true, true).is_none()); + } + assert!(place(10.0, 10.0, 0, 10, false, false).is_none()); + } + + #[test] + fn fit_centres_the_photo() { + let mut p = page(); + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap(); + // 40x20 scaled to 80x40, centred vertically at y 30..70. + assert_eq!(at(&p, 10, 50), RED); + assert_eq!(at(&p, 89, 50), BLUE); + assert_eq!(at(&p, 50, 20), WHITE, "above the photo"); + assert_eq!(at(&p, 50, 75), WHITE, "below the photo"); + assert_eq!(at(&p, 5, 50), WHITE, "outside the cell"); + } + + #[test] + fn zoom_to_fill_crops_the_centre() { + let mut p = page(); + let s = CellStyle { fill: true, ..style() }; + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap(); + // The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell. + for y in [10, 50, 89] { + assert_ne!(at(&p, 10, y), WHITE); + assert_ne!(at(&p, 89, y), WHITE); + } + assert_eq!(at(&p, 20, 50), RED); + assert_eq!(at(&p, 80, 50), BLUE); + // Nothing leaks outside the cell. + assert_eq!(at(&p, 9, 50), WHITE); + assert_eq!(at(&p, 90, 50), WHITE); + assert_eq!(at(&p, 50, 9), WHITE); + assert_eq!(at(&p, 50, 90), WHITE); + } + + #[test] + fn rotate_to_fit_turns_clockwise() { + // A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell. + let mut p = page(); + let cell = PxRect { x: 10, y: 10, w: 80, h: 40 }; + draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap(); + assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle"); + assert_eq!(at(&p, 45, 30), RED); + let mut q = page(); + draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap(); + // Turned clockwise the left half (red) becomes the top half and the photo spans the cell. + assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12)); + assert_eq!(at(&q, 12, 48), BLUE); + assert_eq!(at(&q, 50, 20), RED); + assert_eq!(at(&q, 50, 40), BLUE); + assert_eq!(at(&q, 11, 30), at(&q, 11, 30)); + } + + #[test] + fn border_frames_the_photo_inside_the_cell() { + let mut p = page(); + let green = [100, 60000, 100]; + let s = CellStyle { border_px: 5, border: green, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap(); + assert_eq!(at(&p, 10, 40), green); + assert_eq!(at(&p, 14, 40), green); + assert_eq!(at(&p, 15, 40), RED); + assert_eq!(at(&p, 69, 40), green); + assert_eq!(at(&p, 64, 40), BLUE); + assert_eq!(at(&p, 40, 10), green); + assert_eq!(at(&p, 40, 69), green); + // A fitted photo gets the border hugging it, not the whole cell. + let mut q = page(); + let s = CellStyle { border_px: 5, border: green, ..style() }; + draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap(); + // The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick. + assert_eq!(at(&q, 50, 23), green); + assert_eq!(at(&q, 50, 22), green); + assert_eq!(at(&q, 50, 21), WHITE); + // A border wider than the cell leaves the photo visible. + let mut r = page(); + let s = CellStyle { border_px: 500, border: green, ..style() }; + draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap(); + assert_ne!(at(&r, 20, 20), WHITE); + } + + #[test] + fn stroke_is_drawn_inside_the_photo_edge() { + let mut p = page(); + let black = [0, 0, 0]; + let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap(); + for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] { + assert_eq!(at(&p, x, y), black, "({x}, {y})"); + } + assert_eq!(at(&p, 23, 40), RED); + assert_eq!(at(&p, 76, 40), BLUE); + assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo"); + // Corners are covered exactly once and a huge stroke stays within the photo. + let mut q = page(); + let s = CellStyle { stroke_px: 1000, stroke: black, ..style() }; + draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap(); + assert_eq!(at(&q, 40, 40), black); + assert_eq!(at(&q, 19, 40), WHITE); + assert_eq!(at(&q, 60, 40), WHITE); + } + + #[test] + fn colours_are_rec2020_encoded() { + assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]); + assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]); + let grey = rec2020_from_srgb8([128; 3]); + assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3); + // sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero. + let red = rec2020_from_srgb8([255, 0, 0]); + assert!(red[0] < 65535 && red[1] > 0); + } + + #[test] + fn resize_preserves_flat_colour_and_edges() { + let img = halves(64, 32); + for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] { + let r = img.resized(w, h).unwrap(); + assert_eq!((r.w, r.h), (w, h)); + assert_eq!(r.rgb.len(), w * h * 3); + } + let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap(); + assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999])); + assert!(img.resized(0, 5).is_err()); + let r = img.resized(32, 16).unwrap(); + assert_eq!(at(&r, 4, 8), RED); + assert_eq!(at(&r, 28, 8), BLUE); + } + + #[test] + fn rotate_and_blit_are_clipped() { + let img = halves(3, 2); + let r = img.rotated_cw().unwrap(); + assert_eq!((r.w, r.h), (2, 3)); + let mut p = page(); + p.blit(&img, 0, 0, 99, 99, 10, 10); + assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); + p.blit(&img, 5, 5, 0, 0, 3, 3); + p.fill_rect(95, 95, 50, 50, RED); + assert_eq!(at(&p, 99, 99), RED); + p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); + } + + fn settings(style: Style) -> PrintSettings { + let mut s = PrintSettings::default(); + s.layout.style = style; + s.layout.page.paper = "4x6".into(); + s.layout.page.margins = Margins::uniform(0.5); + s.job.ppi = 100.0; + s + } + + #[test] + fn plans_follow_the_layout_style() { + let ids: Vec = (1..=5).map(PhotoId).collect(); + let mut s = settings(Style::ContactSheet); + s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() }; + let plan = plan_cells(&s.layout, &ids).unwrap(); + assert_eq!(plan.iter().map(Vec::len).collect::>(), vec![4, 1]); + assert_eq!(plan[1][0].1, PhotoId(5)); + let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap(); + assert_eq!(one.len(), 5); + assert!(plan_cells(&s.layout, &[]).is_err()); + assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err()); + + let mut pkg = settings(Style::PicturePackage); + assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet"); + pkg.layout.add_cell(1.0, 1.0).unwrap(); + pkg.layout.add_cell(2.0, 2.0).unwrap(); + let plan = plan_cells(&pkg.layout, &ids).unwrap(); + assert_eq!(plan.len(), 1); + assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo"); + + let mut custom = settings(Style::CustomPackage); + let a = custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.set_cell_photo(a, Some(3)).unwrap(); + let plan = plan_cells(&custom.layout, &ids).unwrap(); + assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1))); + let blank_page = { + custom.layout.new_page().unwrap(); + plan_cells(&custom.layout, &ids).unwrap() + }; + assert_eq!(blank_page.iter().map(Vec::len).collect::>(), vec![2, 0]); + } + + fn first_ids(s: &Session, n: usize) -> Vec { + s.catalog.photos().take(n).map(|p| p.id).collect() + } + + #[test] + fn composes_pages_from_real_photos() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 3); + let mut set = settings(Style::ContactSheet); + set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false }; + set.page.background = [200, 30, 30]; + set.image.zoom_to_fill = true; + set.image.stroke_enabled = true; + set.image.stroke_width_pt = 2.0; + set.image.stroke_color = [0, 0, 0]; + let work = prepare(&mut s, &set, &ids).unwrap(); + assert_eq!((work.len(), work.pages()), (3, 2)); + let mut steps = Vec::new(); + let pages = work + .run(&mut |i, name| { + steps.push((i, name.to_string())); + true + }) + .unwrap(); + assert_eq!(steps.iter().map(|s| s.0).collect::>(), vec![0, 1, 2, 3]); + assert_eq!(pages.len(), 2); + let first = &pages[0]; + assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0)); + assert_eq!(first.rgb.len(), 400 * 600 * 3); + let bg = rec2020_from_srgb8([200, 30, 30]); + let px = |p: &PageRaster, x: usize, y: usize| { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + }; + assert_eq!(px(first, 5, 5), bg, "margin shows the page background"); + assert_eq!(px(first, 395, 595), bg); + // Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour. + assert_eq!(px(first, 50, 50), [0, 0, 0]); + let centre = px(first, 200, 150); + assert_ne!(centre, bg); + assert_ne!(centre, [0, 0, 0]); + // The second page holds the third photo in the first cell only: the second cell is bare. + let second = &pages[1]; + assert_ne!(px(second, 200, 150), bg); + assert_eq!(px(second, 200, 450), bg); + // Cancelling gives no pages. + let work = prepare(&mut s, &set, &ids).unwrap(); + assert!(work.run(&mut |i, _| i < 1).is_err()); + } + + #[test] + fn package_renders_each_size_and_border() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::PicturePackage); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.image.border_enabled = true; + set.image.border_width = 0.1; + set.image.border_color = [10, 200, 10]; + let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap(); + assert_eq!(pages.len(), 1); + let green = rec2020_from_srgb8([10, 200, 10]); + let cell = set.layout.cells[0].rect; + let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize); + let at = |x: usize, y: usize| { + let i = (y * 400 + x) * 3; + pages[0].rgb[i..i + 3].to_vec() + }; + // The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round. + let mid = (x0 + 75, y0 + 50); + assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle"); + let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count(); + assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); + } + + #[test] + fn caps_and_hostile_settings_are_refused() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::SingleImage); + set.layout.page.paper = "custom".into(); + set.layout.page.custom_width = 60.0; + set.layout.page.custom_height = 60.0; + set.job.ppi = 720.0; + assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap"); + let mut set = settings(Style::SingleImage); + set.job.ppi = f32::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + let mut set = settings(Style::SingleImage); + set.image.border_width = f64::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + set.image.border_width = 0.1; + set.image.stroke_width_pt = -1.0; + assert!(prepare(&mut s, &set, &ids).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err()); + let set = settings(Style::SingleImage); + assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages"); + // 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap. + let mut big = settings(Style::SingleImage); + big.job.ppi = 720.0; + assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err()); + } +} diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs new file mode 100644 index 000000000..26d1818e5 --- /dev/null +++ b/crates/engine/src/print/layout.rs @@ -0,0 +1,1332 @@ +//! The Print module's page layout model: paper, margins, layout styles and cells. +//! +//! Pure data and geometry, no rendering. Every length is in **inches** in page space: the origin +//! is the top-left corner of the page *as printed* (after orientation), x grows right and y down. +//! Rulers show other units ([`Unit`]) but convert at the edge; the model never stores them. +//! +//! Four layout styles ([`Style`]): +//! * `singleImage`: one photo fills the printable area of each page; +//! * `contactSheet`: a grid of identical cells ([`GridSpec`]), the photos paginated over pages; +//! * `picturePackage`: one photo at several cell sizes, packed onto pages by [`Layout::auto_layout`]; +//! * `customPackage`: free placement of cells (overlap allowed, [`Stack`] order), each with its own photo. +//! +//! Package cells live in [`Layout::cells`] in stacking order (later = on top). Everything that +//! arrives from outside (commands, saved templates) passes through [`Layout::validate`]; all +//! setters reject NaN, infinite, negative and oversized numbers instead of storing them. +use serde::{Deserialize, Serialize}; + +/// Largest page edge accepted, inches. +pub const MAX_PAGE_IN: f64 = 60.0; +/// Smallest page edge accepted, inches. +pub const MIN_PAGE_IN: f64 = 1.0; +/// Smallest cell edge accepted, inches. +pub const MIN_CELL_IN: f64 = 0.1; +/// Package layouts hold at most this many cells. +pub const MAX_CELLS: usize = 200; +/// Package layouts span at most this many pages. +pub const MAX_PAGES: u32 = 50; +/// Largest margin accepted, inches. +pub const MAX_MARGIN_IN: f64 = 10.0; +/// The printable area must stay at least this wide and tall, inches. +pub const MIN_PRINTABLE_IN: f64 = 0.5; +/// Largest grid dimension of a contact sheet. +pub const MAX_GRID: usize = 20; +/// Geometry tolerance, inches (far below one printed pixel at any supported resolution). +pub const EPS: f64 = 1e-6; + +/// Paper sizes by name: portrait width and height in millimetres (ISO) or inches (US/photo). +const PAPER: &[(&str, f64, f64)] = &[ + ("a3", 297.0 / 25.4, 420.0 / 25.4), + ("a4", 210.0 / 25.4, 297.0 / 25.4), + ("a5", 148.0 / 25.4, 210.0 / 25.4), + ("letter", 8.5, 11.0), + ("legal", 8.5, 14.0), + ("tabloid", 11.0, 17.0), + ("4x6", 4.0, 6.0), + ("5x7", 5.0, 7.0), + ("8x10", 8.0, 10.0), + ("13x19", 13.0, 19.0), +]; + +/// Names accepted by [`PageSetup::paper`] (besides `custom`). +pub fn paper_names() -> impl Iterator { + PAPER.iter().map(|p| p.0) +} + +/// Portrait size in inches of a named paper (case-insensitive), `None` for unknown names and `custom`. +pub fn paper_size(name: &str) -> Option<(f64, f64)> { + let name = name.to_ascii_lowercase(); + PAPER.iter().find(|p| p.0 == name).map(|p| (p.1, p.2)) +} + +/// An axis-aligned rectangle in inches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +impl Rect { + pub const fn new(x: f64, y: f64, w: f64, h: f64) -> Self { + Self { x, y, w, h } + } + pub fn right(&self) -> f64 { + self.x + self.w + } + pub fn bottom(&self) -> f64 { + self.y + self.h + } + pub fn area(&self) -> f64 { + self.w * self.h + } + pub fn is_finite(&self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.w.is_finite() && self.h.is_finite() + } + /// Whether the interiors overlap by more than [`EPS`] on both axes (touching edges do not count). + pub fn overlaps(&self, o: &Rect) -> bool { + self.x < o.right() - EPS && o.x < self.right() - EPS && self.y < o.bottom() - EPS && o.y < self.bottom() - EPS + } + /// Whether `o` lies entirely inside `self`, within [`EPS`]. + pub fn contains(&self, o: &Rect) -> bool { + o.x >= self.x - EPS && o.y >= self.y - EPS && o.right() <= self.right() + EPS && o.bottom() <= self.bottom() + EPS + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Orientation { + #[default] + Portrait, + Landscape, +} + +/// Ruler and dimension units. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Unit { + #[default] + #[serde(rename = "in")] + Inches, + #[serde(rename = "cm")] + Centimeters, + #[serde(rename = "mm")] + Millimeters, + #[serde(rename = "pt")] + Points, +} + +impl Unit { + pub fn per_inch(self) -> f64 { + match self { + Unit::Inches => 1.0, + Unit::Centimeters => 2.54, + Unit::Millimeters => 25.4, + Unit::Points => 72.0, + } + } + pub fn from_inches(self, inches: f64) -> f64 { + inches * self.per_inch() + } + pub fn to_inches(self, value: f64) -> f64 { + value / self.per_inch() + } + pub fn label(self) -> &'static str { + match self { + Unit::Inches => "in", + Unit::Centimeters => "cm", + Unit::Millimeters => "mm", + Unit::Points => "pt", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Margins { + pub top: f64, + pub right: f64, + pub bottom: f64, + pub left: f64, +} + +impl Default for Margins { + fn default() -> Self { + Self::uniform(0.25) + } +} + +impl Margins { + pub const fn uniform(v: f64) -> Self { + Self { top: v, right: v, bottom: v, left: v } + } +} + +/// Paper, orientation and margins (Lightroom's Page Setup + the Page margins). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PageSetup { + /// A name from [`paper_names`] or `custom`. + pub paper: String, + /// Portrait size of a `custom` paper, inches. + pub custom_width: f64, + pub custom_height: f64, + pub orientation: Orientation, + pub margins: Margins, +} + +impl Default for PageSetup { + fn default() -> Self { + Self { paper: "letter".into(), custom_width: 8.5, custom_height: 11.0, orientation: Orientation::Portrait, margins: Margins::default() } + } +} + +fn check(v: f64, what: &str, min: f64, max: f64) -> Result { + if v.is_finite() && v >= min && v <= max { Ok(v) } else { Err(format!("{what} must be a number from {min} to {max}")) } +} + +impl PageSetup { + /// Page width and height in inches after orientation. + pub fn size(&self) -> Result<(f64, f64), String> { + let (w, h) = if self.paper.eq_ignore_ascii_case("custom") { + ( + check(self.custom_width, "custom width", MIN_PAGE_IN, MAX_PAGE_IN)?, + check(self.custom_height, "custom height", MIN_PAGE_IN, MAX_PAGE_IN)?, + ) + } else { + paper_size(&self.paper) + .ok_or_else(|| format!("unknown paper `{}` (one of {} or custom)", self.paper, paper_names().collect::>().join(", ")))? + }; + Ok(match self.orientation { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + }) + } + + /// The area inside the margins. + pub fn printable(&self) -> Result { + let (w, h) = self.size()?; + let m = &self.margins; + let (t, r, b, l) = ( + check(m.top, "top margin", 0.0, MAX_MARGIN_IN)?, + check(m.right, "right margin", 0.0, MAX_MARGIN_IN)?, + check(m.bottom, "bottom margin", 0.0, MAX_MARGIN_IN)?, + check(m.left, "left margin", 0.0, MAX_MARGIN_IN)?, + ); + let (pw, ph) = (w - l - r, h - t - b); + if pw < MIN_PRINTABLE_IN || ph < MIN_PRINTABLE_IN { + return Err(format!("margins leave less than {MIN_PRINTABLE_IN} in of printable area")); + } + Ok(Rect::new(l, t, pw, ph)) + } + + /// The page size in PDF points (1/72 in). + pub fn size_points(&self) -> Result<(f64, f64), String> { + let (w, h) = self.size()?; + Ok((w * 72.0, h * 72.0)) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Style { + #[default] + SingleImage, + ContactSheet, + PicturePackage, + CustomPackage, +} + +impl Style { + /// Styles whose cells are listed in [`Layout::cells`]. + pub fn is_package(self) -> bool { + matches!(self, Style::PicturePackage | Style::CustomPackage) + } +} + +/// The contact-sheet grid. `singleImage` ignores it (one cell per page, no spacing). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct GridSpec { + pub columns: usize, + pub rows: usize, + /// Gap between columns / rows, inches. + pub spacing_x: f64, + pub spacing_y: f64, + /// Make the cells square (the grid is centred in the printable area). + pub keep_square: bool, +} + +impl Default for GridSpec { + fn default() -> Self { + Self { columns: 3, rows: 4, spacing_x: 0.1, spacing_y: 0.1, keep_square: false } + } +} + +/// One cell of a package layout. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Cell { + pub id: u32, + /// Zero-based page the cell is on. + pub page: u32, + pub rect: Rect, + /// The photo printed in the cell (Custom Package); `None` uses the job's first photo. + /// Not part of a saved template. + #[serde(skip_serializing_if = "Option::is_none")] + pub photo: Option, +} + +/// Where a cell goes in the stacking order of its page. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Stack { + ToBack, + Backward, + Forward, + ToFront, +} + +/// How dragged cells snap. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Snap { + Off, + /// To the edges of the other cells and of the printable area. + #[default] + Cells, + /// To multiples of [`Guides::grid_size`] from the printable area's corner. + Grid, +} + +/// Rulers, grid and guides (Lightroom's Guides panel). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Guides { + pub show_rulers: bool, + pub units: Unit, + pub snap: Snap, + /// Grid pitch, inches. + pub grid_size: f64, + pub show_margins: bool, + pub show_bleed: bool, + pub show_page_grid: bool, + pub show_cells: bool, + pub show_dimensions: bool, +} + +impl Default for Guides { + fn default() -> Self { + Self { + show_rulers: true, + units: Unit::Inches, + snap: Snap::Cells, + grid_size: 0.25, + show_margins: true, + show_bleed: false, + show_page_grid: false, + show_cells: true, + show_dimensions: false, + } + } +} + +impl Guides { + pub fn validate(&self) -> Result<(), String> { + check(self.grid_size, "grid size", 0.05, 10.0).map(|_| ()) + } +} + +/// The page layout: setup, style and cells. See the module docs for the units and style rules. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Layout { + pub style: Style, + pub page: PageSetup, + pub grid: GridSpec, + /// Gap kept between cells by [`Layout::auto_layout`] and new cells, inches. + pub spacing: f64, + /// Pages of a package layout (at least 1 and past every cell). + pub pages: u32, + /// Package cells in stacking order, back to front. + pub cells: Vec, + next_id: u32, +} + +impl Default for Layout { + fn default() -> Self { + Self { + style: Style::SingleImage, + page: PageSetup::default(), + grid: GridSpec::default(), + spacing: 0.1, + pages: 1, + cells: Vec::new(), + next_id: 1, + } + } +} + +/// Free space on one page as a set of maximal rectangles (the MaxRects scheme). +struct Free(Vec); + +impl Free { + fn new(area: Rect) -> Self { + Self(vec![area]) + } + + /// Removes `used` from the free space. + fn occupy(&mut self, used: &Rect) { + let mut next = Vec::with_capacity(self.0.len() + 4); + for f in &self.0 { + if !f.overlaps(used) { + next.push(*f); + continue; + } + if used.x > f.x + EPS { + next.push(Rect::new(f.x, f.y, used.x - f.x, f.h)); + } + if used.right() < f.right() - EPS { + next.push(Rect::new(used.right(), f.y, f.right() - used.right(), f.h)); + } + if used.y > f.y + EPS { + next.push(Rect::new(f.x, f.y, f.w, used.y - f.y)); + } + if used.bottom() < f.bottom() - EPS { + next.push(Rect::new(f.x, used.bottom(), f.w, f.bottom() - used.bottom())); + } + } + // Drop rectangles contained in another (the first of two equal ones survives). + let mut keep: Vec = Vec::with_capacity(next.len()); + for (i, r) in next.iter().enumerate() { + let dominated = next.iter().enumerate().any(|(j, o)| i != j && o.contains(r) && (!r.contains(o) || j < i)); + if !dominated { + keep.push(*r); + } + } + self.0 = keep; + } + + /// The best spot for a `w` x `h` block: least leftover on the short side, then the long side, + /// then top-most and left-most. The block is turned a quarter (when `rotate`) only if it fits + /// nowhere as given, so cells keep their shape whenever they can. Returns the spot and + /// whether the block was turned. + fn best_fit(&self, w: f64, h: f64, rotate: bool) -> Option<(Rect, bool)> { + self.fit_block(w, h) + .map(|r| (r, false)) + .or_else(|| if rotate && (w - h).abs() >= EPS { self.fit_block(h, w).map(|r| (r, true)) } else { None }) + } + + fn fit_block(&self, bw: f64, bh: f64) -> Option { + let mut best: Option<((f64, f64, f64, f64), Rect)> = None; + let eq = |a: f64, b: f64| (a - b).abs() < EPS; + for f in &self.0 { + if bw > f.w + EPS || bh > f.h + EPS { + continue; + } + let (dw, dh) = (f.w - bw, f.h - bh); + let key = (dw.min(dh), dw.max(dh), f.y, f.x); + let better = best.as_ref().is_none_or(|(k, _)| { + if !eq(k.0, key.0) { + return key.0 < k.0; + } + if !eq(k.1, key.1) { + return key.1 < k.1; + } + if !eq(k.2, key.2) { + return key.2 < k.2; + } + key.3 < k.3 - EPS + }); + if better { + best = Some((key, Rect::new(f.x, f.y, bw, bh))); + } + } + best.map(|(_, r)| r) + } +} + +/// Where each block of [`pack`] went: page index, rectangle in page space and whether it was turned. +pub type Placement = (u32, Rect, bool); + +/// Packs blocks (width, height) onto pages of `area`, keeping `spacing` between them. +/// +/// Deterministic: blocks are placed largest first (area, then longer side, then input order) with +/// the MaxRects best-short-side-fit rule, filling earlier pages before opening a new one. Blocks +/// may be turned a quarter when `rotate`. Fails when a block cannot fit an empty page or the +/// result would need more than [`MAX_PAGES`] pages. The result is in input order. +pub fn pack(sizes: &[(f64, f64)], area: Rect, spacing: f64, rotate: bool) -> Result, String> { + if !area.is_finite() || area.w <= 0.0 || area.h <= 0.0 { + return Err("no printable area to pack into".into()); + } + let spacing = check(spacing, "spacing", 0.0, MAX_PAGE_IN)?; + if sizes.len() > MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + for (w, h) in sizes { + check(*w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?; + check(*h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?; + } + // Each block carries `spacing` on its right and bottom; the area grows by the same so a block + // flush with the margin still fits. + let padded = Rect::new(area.x, area.y, area.w + spacing, area.h + spacing); + let mut order: Vec = (0..sizes.len()).collect(); + order.sort_by(|&a, &b| { + let (sa, sb) = (sizes.get(a).copied().unwrap_or_default(), sizes.get(b).copied().unwrap_or_default()); + (sb.0 * sb.1).total_cmp(&(sa.0 * sa.1)).then((sb.0.max(sb.1)).total_cmp(&sa.0.max(sa.1))).then(a.cmp(&b)) + }); + let mut pages: Vec = Vec::new(); + let mut out: Vec> = vec![None; sizes.len()]; + for i in order { + let (w, h) = sizes.get(i).copied().unwrap_or_default(); + let (bw, bh) = (w + spacing, h + spacing); + let mut placed = None; + for (p, free) in pages.iter().enumerate() { + if let Some(hit) = free.best_fit(bw, bh, rotate) { + placed = Some((p, hit)); + break; + } + } + let (p, (spot, turned)) = match placed { + Some(v) => v, + None => { + if pages.len() as u32 >= MAX_PAGES { + return Err(format!("the cells need more than {MAX_PAGES} pages")); + } + let fresh = Free::new(padded); + let hit = fresh.best_fit(bw, bh, rotate).ok_or_else(|| format!("a {w:.2} x {h:.2} in cell does not fit the printable area"))?; + pages.push(fresh); + (pages.len() - 1, hit) + } + }; + if let Some(free) = pages.get_mut(p) { + free.occupy(&spot); + } + let (cw, ch) = if turned { (h, w) } else { (w, h) }; + if let Some(slot) = out.get_mut(i) { + *slot = Some((p as u32, Rect::new(spot.x, spot.y, cw, ch), turned)); + } + } + out.into_iter().map(|p| p.ok_or_else(|| "internal packing error".to_string())).collect() +} + +/// Snaps a span (`start`, `len`) along one axis so its start or end lands on the nearest candidate +/// within `threshold`; unchanged when none is close. +fn snap_span(start: f64, len: f64, candidates: &[f64], threshold: f64) -> f64 { + let mut best: Option<(f64, f64)> = None; + for &c in candidates { + for shift in [c - start, c - (start + len)] { + if shift.abs() <= threshold && best.is_none_or(|(d, _)| shift.abs() < d) { + best = Some((shift.abs(), shift)); + } + } + } + start + best.map_or(0.0, |b| b.1) +} + +/// Snaps one coordinate (a dragged edge) to the nearest candidate within `threshold`. +fn snap_edge(v: f64, candidates: &[f64], threshold: f64) -> f64 { + candidates.iter().copied().filter(|c| (c - v).abs() <= threshold).min_by(|a, b| (a - v).abs().total_cmp(&(b - v).abs())).unwrap_or(v) +} + +impl Layout { + /// Page size in inches after orientation. + pub fn page_size(&self) -> Result<(f64, f64), String> { + self.page.size() + } + + /// The printable area (inside the margins). + pub fn printable(&self) -> Result { + self.page.printable() + } + + /// Checks every number and cross-reference; also repairs `next_id` so ids stay unique. + /// Run on anything that did not come from the setters (commands, templates). + pub fn validate(&mut self) -> Result<(), String> { + let area = self.page.printable()?; + check(self.spacing, "spacing", 0.0, 10.0)?; + if !(1..=MAX_GRID).contains(&self.grid.columns) || !(1..=MAX_GRID).contains(&self.grid.rows) { + return Err(format!("columns and rows must be 1 to {MAX_GRID}")); + } + check(self.grid.spacing_x, "column spacing", 0.0, 10.0)?; + check(self.grid.spacing_y, "row spacing", 0.0, 10.0)?; + for r in self.grid_rects_for(area)? { + if r.w < MIN_CELL_IN || r.h < MIN_CELL_IN { + return Err("the grid leaves cells smaller than 0.1 in".into()); + } + } + if self.cells.len() > MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + if !(1..=MAX_PAGES).contains(&self.pages) { + return Err(format!("pages must be 1 to {MAX_PAGES}")); + } + let mut seen = std::collections::BTreeSet::new(); + for c in &self.cells { + if !seen.insert(c.id) { + return Err(format!("duplicate cell id {}", c.id)); + } + if c.page >= self.pages { + return Err(format!("cell {} is on page {} but the layout has {} pages", c.id, c.page + 1, self.pages)); + } + self.check_rect(&c.rect, area)?; + } + self.next_id = self.next_id.max(self.cells.iter().map(|c| c.id).max().unwrap_or(0).saturating_add(1)).max(1); + Ok(()) + } + + fn check_rect(&self, r: &Rect, area: Rect) -> Result<(), String> { + if !r.is_finite() || r.w < MIN_CELL_IN - EPS || r.h < MIN_CELL_IN - EPS { + return Err(format!("cells must be at least {MIN_CELL_IN} in wide and tall")); + } + if !area.contains(r) { + return Err("a cell lies outside the printable area".into()); + } + Ok(()) + } + + /// The cells of one grid page in reading order (`singleImage`: one cell filling the area). + pub fn grid_rects(&self) -> Result, String> { + self.grid_rects_for(self.page.printable()?) + } + + fn grid_rects_for(&self, area: Rect) -> Result, String> { + if self.style == Style::SingleImage { + return Ok(vec![area]); + } + let g = &self.grid; + let (cols, rows) = (g.columns.clamp(1, MAX_GRID), g.rows.clamp(1, MAX_GRID)); + let gaps_x = g.spacing_x * (cols - 1) as f64; + let gaps_y = g.spacing_y * (rows - 1) as f64; + let mut cw = (area.w - gaps_x) / cols as f64; + let mut ch = (area.h - gaps_y) / rows as f64; + if !(cw > 0.0 && ch > 0.0) { + return Err("the grid spacing leaves no room for cells".into()); + } + let (mut ox, mut oy) = (area.x, area.y); + if g.keep_square { + let side = cw.min(ch); + ox += (cw - side) * cols as f64 / 2.0; + oy += (ch - side) * rows as f64 / 2.0; + cw = side; + ch = side; + } + let mut out = Vec::with_capacity(cols * rows); + for r in 0..rows { + for c in 0..cols { + out.push(Rect::new(ox + c as f64 * (cw + g.spacing_x), oy + r as f64 * (ch + g.spacing_y), cw, ch)); + } + } + Ok(out) + } + + /// Cells per page of a grid style; 1 for the package styles. + pub fn cells_per_page(&self) -> usize { + match self.style { + Style::SingleImage => 1, + Style::ContactSheet => self.grid.columns.clamp(1, MAX_GRID) * self.grid.rows.clamp(1, MAX_GRID), + Style::PicturePackage | Style::CustomPackage => 1, + } + } + + /// Pages a print of `photos` photos needs: the photos over the grid for the grid styles, the + /// layout's own pages for the packages. + pub fn page_count(&self, photos: usize) -> usize { + match self.style { + Style::SingleImage | Style::ContactSheet => photos.max(1).div_ceil(self.cells_per_page()), + Style::PicturePackage | Style::CustomPackage => self.pages.max(1) as usize, + } + } + + pub fn cell(&self, id: u32) -> Option<&Cell> { + self.cells.iter().find(|c| c.id == id) + } + + fn index(&self, id: u32) -> Result { + self.cells.iter().position(|c| c.id == id).ok_or_else(|| format!("no cell {id}")) + } + + /// Cells of one page, back to front. + pub fn cells_on_page(&self, page: u32) -> impl Iterator { + self.cells.iter().filter(move |c| c.page == page) + } + + /// Sets the layout style. Entering a package style from a grid style keeps existing cells. + pub fn set_style(&mut self, style: Style) { + self.style = style; + } + + fn require_package(&self) -> Result<(), String> { + if self.style.is_package() { Ok(()) } else { Err("cells exist only in the Picture Package and Custom Package styles".into()) } + } + + /// Adds a `w` x `h` in cell at the first free spot of the earliest page with room (a new page + /// when none has), and returns its id. A cell too large for the printable area is refused. + pub fn add_cell(&mut self, w: f64, h: f64) -> Result { + self.require_package()?; + let area = self.printable()?; + check(w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?; + check(h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?; + if self.cells.len() >= MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + if w > area.w + EPS || h > area.h + EPS { + return Err(format!("a {w:.2} x {h:.2} in cell is larger than the printable area ({:.2} x {:.2} in)", area.w, area.h)); + } + let padded = Rect::new(area.x, area.y, area.w + self.spacing, area.h + self.spacing); + let spot = (0..self.pages).find_map(|p| { + let mut free = Free::new(padded); + for c in self.cells_on_page(p) { + free.occupy(&Rect::new(c.rect.x, c.rect.y, c.rect.w + self.spacing, c.rect.h + self.spacing)); + } + free.best_fit(w + self.spacing, h + self.spacing, false).map(|(r, _)| (p, r)) + }); + let (page, at) = match spot { + Some(v) => v, + None => { + if self.pages >= MAX_PAGES { + return Err(format!("a layout holds at most {MAX_PAGES} pages")); + } + self.pages += 1; + (self.pages - 1, Rect::new(area.x, area.y, w, h)) + } + }; + let id = self.take_id(); + self.cells.push(Cell { id, page, rect: Rect::new(at.x, at.y, w, h), photo: None }); + Ok(id) + } + + fn take_id(&mut self) -> u32 { + let id = self.next_id.max(1); + self.next_id = id.saturating_add(1); + id + } + + /// Moves a cell so its top-left corner is at (`x`, `y`), kept inside the printable area. + pub fn move_cell(&mut self, id: u32, x: f64, y: f64) -> Result { + self.require_package()?; + let area = self.printable()?; + if !x.is_finite() || !y.is_finite() { + return Err("position must be finite".into()); + } + let i = self.index(id)?; + let cell = self.cells.get_mut(i).ok_or("no such cell")?; + let r = &mut cell.rect; + r.x = x.clamp(area.x, (area.right() - r.w).max(area.x)); + r.y = y.clamp(area.y, (area.bottom() - r.h).max(area.y)); + Ok(*r) + } + + /// Gives a cell a new rectangle (position and size), clamped into the printable area. + pub fn resize_cell(&mut self, id: u32, rect: Rect) -> Result { + self.require_package()?; + let area = self.printable()?; + if !rect.is_finite() { + return Err("rectangle must be finite".into()); + } + let w = check(rect.w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?.min(area.w); + let h = check(rect.h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?.min(area.h); + let i = self.index(id)?; + let cell = self.cells.get_mut(i).ok_or("no such cell")?; + cell.rect = Rect::new(rect.x.clamp(area.x, (area.right() - w).max(area.x)), rect.y.clamp(area.y, (area.bottom() - h).max(area.y)), w, h); + Ok(cell.rect) + } + + pub fn delete_cell(&mut self, id: u32) -> Result<(), String> { + let i = self.index(id)?; + self.cells.remove(i); + Ok(()) + } + + /// Removes every cell and extra page (Lightroom's Clear Layout). + pub fn clear(&mut self) { + self.cells.clear(); + self.pages = 1; + } + + /// Appends an empty page to a package layout and returns its zero-based index. + pub fn new_page(&mut self) -> Result { + self.require_package()?; + if self.pages >= MAX_PAGES { + return Err(format!("a layout holds at most {MAX_PAGES} pages")); + } + self.pages += 1; + Ok(self.pages - 1) + } + + /// Moves a cell to another page, keeping its rectangle (clamped inside the printable area). + pub fn set_cell_page(&mut self, id: u32, page: u32) -> Result<(), String> { + self.require_package()?; + if page >= self.pages { + return Err(format!("the layout has {} pages", self.pages)); + } + let i = self.index(id)?; + if let Some(c) = self.cells.get_mut(i) { + c.page = page; + } + Ok(()) + } + + /// Assigns (or with `None` clears) the photo printed in a cell. + pub fn set_cell_photo(&mut self, id: u32, photo: Option) -> Result<(), String> { + let i = self.index(id)?; + if let Some(c) = self.cells.get_mut(i) { + c.photo = photo; + } + Ok(()) + } + + /// Changes a cell's place in the stacking order of its page (Send to Back, Backward, Forward, + /// Bring to Front). Cells on other pages are not moved past. + pub fn restack(&mut self, id: u32, how: Stack) -> Result<(), String> { + let i = self.index(id)?; + let page = self.cells.get(i).map(|c| c.page).ok_or("no such cell")?; + let same: Vec = self.cells.iter().enumerate().filter(|(_, c)| c.page == page).map(|(k, _)| k).collect(); + let pos = same.iter().position(|&k| k == i).ok_or("no such cell")?; + let target = match how { + Stack::ToBack => 0, + Stack::Backward => pos.saturating_sub(1), + Stack::Forward => (pos + 1).min(same.len().saturating_sub(1)), + Stack::ToFront => same.len().saturating_sub(1), + }; + if target == pos { + return Ok(()); + } + let cell = self.cells.remove(i); + // Index in the shortened vector where the target neighbour now sits. + let at = same.get(target).map(|&k| if k > i { k - 1 } else { k }).ok_or("no such cell")?; + let at = if target > pos { at + 1 } else { at }; + self.cells.insert(at.min(self.cells.len()), cell); + Ok(()) + } + + /// Re-packs every cell of the package onto as few pages as possible (Auto Layout). Cells keep + /// their sizes (a cell may be turned a quarter to fit) and their stacking order; their + /// photos are untouched. Fails, changing nothing, when a cell cannot fit the printable area. + pub fn auto_layout(&mut self) -> Result<(), String> { + self.require_package()?; + let area = self.printable()?; + let sizes: Vec<(f64, f64)> = self.cells.iter().map(|c| (c.rect.w, c.rect.h)).collect(); + let placed = pack(&sizes, area, self.spacing, true)?; + let mut pages = 1; + for (c, (page, rect, _)) in self.cells.iter_mut().zip(placed) { + c.page = page; + c.rect = rect; + pages = pages.max(page + 1); + } + self.pages = pages; + Ok(()) + } + + /// Pulls every cell back inside the printable area (shrinking one that is bigger than it), for + /// after the paper, orientation or margins changed. + pub fn fit_cells(&mut self) -> Result<(), String> { + let area = self.printable()?; + for c in &mut self.cells { + let r = &mut c.rect; + if !r.is_finite() { + continue; + } + r.w = r.w.min(area.w); + r.h = r.h.min(area.h); + r.x = r.x.clamp(area.x, (area.right() - r.w).max(area.x)); + r.y = r.y.clamp(area.y, (area.bottom() - r.h).max(area.y)); + } + Ok(()) + } + + /// Whether two cells on the same page overlap. + pub fn has_overlaps(&self) -> bool { + self.cells.iter().enumerate().any(|(i, a)| self.cells.iter().skip(i + 1).any(|b| a.page == b.page && a.rect.overlaps(&b.rect))) + } + + /// Snap candidates along each axis for cell `skip` on `page`: edges of other cells and of the + /// printable area, or grid lines. + fn candidates(&self, page: u32, skip: Option, snap: Snap, grid: f64) -> Result<(Vec, Vec), String> { + let area = self.printable()?; + let (mut xs, mut ys) = (Vec::new(), Vec::new()); + match snap { + Snap::Off => {} + Snap::Cells => { + xs.extend([area.x, area.right()]); + ys.extend([area.y, area.bottom()]); + for c in self.cells_on_page(page).filter(|c| Some(c.id) != skip) { + xs.extend([c.rect.x, c.rect.right()]); + ys.extend([c.rect.y, c.rect.bottom()]); + } + } + Snap::Grid => { + let grid = check(grid, "grid size", 0.05, 10.0)?; + let n = |len: f64| (len / grid).floor().min(4000.0) as usize; + xs.extend((0..=n(area.w)).map(|k| area.x + k as f64 * grid)); + ys.extend((0..=n(area.h)).map(|k| area.y + k as f64 * grid)); + xs.push(area.right()); + ys.push(area.bottom()); + } + } + Ok((xs, ys)) + } + + /// Snaps a cell being dragged: `rect` keeps its size, and its left or right edge (likewise top + /// or bottom) lands on a candidate within `threshold` inches. + pub fn snap_move(&self, id: u32, rect: Rect, snap: Snap, grid: f64, threshold: f64) -> Result { + if !rect.is_finite() || !threshold.is_finite() { + return Err("rectangle must be finite".into()); + } + let page = self.cell(id).map(|c| c.page).ok_or_else(|| format!("no cell {id}"))?; + let (xs, ys) = self.candidates(page, Some(id), snap, grid)?; + let area = self.printable()?; + let x = snap_span(rect.x, rect.w, &xs, threshold.max(0.0)); + let y = snap_span(rect.y, rect.h, &ys, threshold.max(0.0)); + Ok(Rect::new(x.clamp(area.x, (area.right() - rect.w).max(area.x)), y.clamp(area.y, (area.bottom() - rect.h).max(area.y)), rect.w, rect.h)) + } + + /// Snaps a cell being resized by its bottom-right corner: the right and bottom edges land on + /// candidates within `threshold`, the top-left corner stays. + pub fn snap_resize(&self, id: u32, rect: Rect, snap: Snap, grid: f64, threshold: f64) -> Result { + if !rect.is_finite() || !threshold.is_finite() { + return Err("rectangle must be finite".into()); + } + let page = self.cell(id).map(|c| c.page).ok_or_else(|| format!("no cell {id}"))?; + let (xs, ys) = self.candidates(page, Some(id), snap, grid)?; + let right = snap_edge(rect.right(), &xs, threshold.max(0.0)); + let bottom = snap_edge(rect.bottom(), &ys, threshold.max(0.0)); + Ok(Rect::new(rect.x, rect.y, (right - rect.x).max(MIN_CELL_IN), (bottom - rect.y).max(MIN_CELL_IN))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package() -> Layout { + Layout { style: Style::PicturePackage, ..Default::default() } + } + + fn assert_clean(l: &Layout) { + let area = l.printable().unwrap(); + for c in &l.cells { + assert!(area.contains(&c.rect), "{c:?} outside {area:?}"); + assert!(c.page < l.pages); + } + assert!(!l.has_overlaps(), "{:#?}", l.cells); + } + + #[test] + fn papers_and_orientation() { + let mut p = PageSetup::default(); + assert_eq!(p.size().unwrap(), (8.5, 11.0)); + p.orientation = Orientation::Landscape; + assert_eq!(p.size().unwrap(), (11.0, 8.5)); + assert_eq!(p.size_points().unwrap(), (792.0, 612.0)); + p.paper = "A4".into(); + let (w, h) = p.size().unwrap(); + assert!((w - 11.693).abs() < 1e-3 && (h - 8.268).abs() < 1e-3); + for n in paper_names() { + p.paper = n.into(); + assert!(p.printable().is_ok(), "{n}"); + } + p.paper = "custom".into(); + p.custom_width = 20.0; + p.custom_height = 30.0; + assert_eq!(p.size().unwrap(), (30.0, 20.0)); + p.paper = "poster".into(); + assert!(p.size().is_err()); + } + + #[test] + fn hostile_numbers_are_rejected() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { + let p = PageSetup { paper: "custom".into(), custom_width: bad, ..Default::default() }; + assert!(p.size().is_err(), "{bad}"); + let p = PageSetup { margins: Margins { left: bad, ..Margins::default() }, ..Default::default() }; + assert!(p.printable().is_err(), "{bad}"); + let mut l = package(); + assert!(l.add_cell(bad, 2.0).is_err(), "{bad}"); + assert!(l.add_cell(2.0, bad).is_err()); + let id = l.add_cell(2.0, 2.0).unwrap(); + assert!(bad.is_finite() || l.move_cell(id, bad, 0.0).is_err()); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, bad, 1.0)).is_err()); + assert!(bad.is_finite() || l.snap_move(id, Rect::new(bad, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + let mut g = Layout { spacing: bad, ..package() }; + assert!(g.validate().is_err()); + assert!(pack(&[(1.0, 1.0)], l.printable().unwrap(), bad, true).is_err()); + assert!(pack(&[(bad, 1.0)], l.printable().unwrap(), 0.0, true).is_err()); + } + // Margins that leave nothing. + let p = PageSetup { margins: Margins::uniform(4.5), ..Default::default() }; + assert!(p.printable().is_err()); + let mut l = Layout { grid: GridSpec { columns: 0, ..Default::default() }, ..Default::default() }; + assert!(l.validate().is_err()); + l.grid.columns = 21; + assert!(l.validate().is_err()); + } + + #[test] + fn units_convert() { + for u in [Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points] { + assert!((u.to_inches(u.from_inches(3.5)) - 3.5).abs() < 1e-12); + } + assert!((Unit::Millimeters.from_inches(1.0) - 25.4).abs() < 1e-12); + assert_eq!(Unit::Points.from_inches(2.0), 144.0); + assert_eq!(serde_json::to_string(&Unit::Centimeters).unwrap(), "\"cm\""); + } + + #[test] + fn contact_grid_fills_the_printable_area() { + let l = Layout { + style: Style::ContactSheet, + grid: GridSpec { columns: 3, rows: 4, spacing_x: 0.2, spacing_y: 0.1, keep_square: false }, + ..Default::default() + }; + let area = l.printable().unwrap(); + let cells = l.grid_rects().unwrap(); + assert_eq!(cells.len(), 12); + let first = cells.first().unwrap(); + let last = cells.last().unwrap(); + assert!((first.x - area.x).abs() < 1e-9 && (first.y - area.y).abs() < 1e-9); + assert!((last.right() - area.right()).abs() < 1e-9 && (last.bottom() - area.bottom()).abs() < 1e-9); + for (i, a) in cells.iter().enumerate() { + assert!((a.w - first.w).abs() < 1e-12 && (a.h - first.h).abs() < 1e-12); + for b in cells.iter().skip(i + 1) { + assert!(!a.overlaps(b)); + } + } + // Square cells are centred. + let sq = Layout { grid: GridSpec { keep_square: true, ..l.grid }, ..l.clone() }; + let cells = sq.grid_rects().unwrap(); + assert!(cells.iter().all(|c| (c.w - c.h).abs() < 1e-12)); + let (lo, hi) = (cells.iter().map(|c| c.x).fold(f64::MAX, f64::min), cells.iter().map(|c| c.right()).fold(f64::MIN, f64::max)); + assert!(((lo - area.x) - (area.right() - hi)).abs() < 1e-9); + // Single image ignores the grid. + let one = Layout { style: Style::SingleImage, ..l }; + assert_eq!(one.grid_rects().unwrap(), vec![area]); + } + + #[test] + fn page_counts() { + let mut l = Layout { style: Style::ContactSheet, grid: GridSpec { columns: 2, rows: 2, ..Default::default() }, ..Default::default() }; + assert_eq!(l.page_count(0), 1); + assert_eq!(l.page_count(4), 1); + assert_eq!(l.page_count(5), 2); + l.style = Style::SingleImage; + assert_eq!(l.page_count(3), 3); + l.style = Style::PicturePackage; + l.pages = 4; + assert_eq!(l.page_count(99), 4); + } + + #[test] + fn packer_is_deterministic_and_clean() { + let area = Layout::default().printable().unwrap(); + let sizes: Vec<(f64, f64)> = + [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (3.5, 5.0), (8.0, 10.0), (4.0, 6.0), (2.0, 2.0), (1.0, 1.5), (5.0, 7.0), (4.0, 6.0)] + .to_vec(); + let a = pack(&sizes, area, 0.1, true).unwrap(); + assert_eq!(a, pack(&sizes, area, 0.1, true).unwrap()); + assert_eq!(a.len(), sizes.len()); + for (i, (p, r, turned)) in a.iter().enumerate() { + assert!(area.contains(r), "{r:?}"); + let (w, h) = sizes[i]; + let expect = if *turned { (h, w) } else { (w, h) }; + assert!((r.w - expect.0).abs() < 1e-9 && (r.h - expect.1).abs() < 1e-9); + for (j, (q, s, _)) in a.iter().enumerate().skip(i + 1) { + if p == q { + // The spacing is kept between neighbours. + let grown = Rect::new(r.x, r.y, r.w + 0.1 - 2e-6, r.h + 0.1 - 2e-6); + assert!(!grown.overlaps(s), "{i} and {j}: {r:?} {s:?}"); + } + } + } + // The input order does not hide a block that needs a page of its own. + assert!(a.iter().map(|p| p.0).max().unwrap() >= 1); + } + + #[test] + fn packer_rotates_only_when_needed_and_refuses_oversize() { + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + // 10 x 5 only fits a portrait page turned. + let r = pack(&[(10.0, 5.0)], area, 0.0, true).unwrap(); + assert!(r[0].2 && (r[0].1.w - 5.0).abs() < 1e-9 && (r[0].1.h - 10.0).abs() < 1e-9); + assert!(pack(&[(10.0, 5.0)], area, 0.0, false).is_err()); + let r = pack(&[(4.0, 6.0)], area, 0.0, true).unwrap(); + assert!(!r[0].2, "a block that fits as given is not turned"); + assert!(pack(&[(9.0, 11.0)], area, 0.0, true).is_err()); + // A block that exactly fills the area fits despite the spacing. + assert!(pack(&[(8.0, 10.5)], area, 0.2, true).is_ok()); + assert!(pack(&vec![(1.0, 1.0); MAX_CELLS + 1], area, 0.0, true).is_err()); + // More pages than allowed. + assert!(pack(&vec![(8.0, 10.5); 51], area, 0.0, true).is_err()); + assert_eq!(pack(&vec![(8.0, 10.5); 50], area, 0.0, true).unwrap().iter().map(|p| p.0).max(), Some(49)); + } + + #[test] + fn packing_is_dense() { + // Four 4x5.25 cells tile a letter page's 8 x 10.5 area exactly. + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + let r = pack(&[(4.0, 5.25); 4], area, 0.0, false).unwrap(); + assert!(r.iter().all(|p| p.0 == 0)); + // Eight 4x6 photos fit two pages (four per page with the rotation trick is impossible: 4x6 x2 per column). + let r = pack(&[(4.0, 6.0); 4], area, 0.0, true).unwrap(); + assert_eq!(r.iter().map(|p| p.0).max(), Some(1)); + } + + #[test] + fn add_cell_fills_gaps_then_opens_pages() { + let mut l = package(); + let a = l.add_cell(3.9, 6.0).unwrap(); + let b = l.add_cell(3.9, 6.0).unwrap(); + assert_eq!(l.cell(a).unwrap().rect.x, 0.25); + assert_ne!(l.cell(a).unwrap().rect, l.cell(b).unwrap().rect); + assert_clean(&l); + // Keep adding until a second page appears. + for _ in 0..6 { + l.add_cell(3.9, 6.0).unwrap(); + } + assert!(l.pages >= 2); + assert_clean(&l); + assert_eq!(l.cells.len(), 8); + assert!(l.add_cell(9.0, 9.0).is_err(), "wider than the printable area"); + let mut grid = Layout::default(); + assert!(grid.add_cell(1.0, 1.0).is_err()); + } + + #[test] + fn cell_cap() { + let mut l = package(); + for _ in 0..MAX_CELLS { + if l.add_cell(0.5, 0.5).is_err() { + break; + } + } + assert!(l.cells.len() <= MAX_CELLS); + let n = l.cells.len(); + if n == MAX_CELLS { + assert!(l.add_cell(0.5, 0.5).is_err()); + } + } + + #[test] + fn move_and_resize_stay_inside() { + let mut l = package(); + let id = l.add_cell(2.0, 3.0).unwrap(); + let r = l.move_cell(id, 100.0, -5.0).unwrap(); + let area = l.printable().unwrap(); + assert!((r.right() - area.right()).abs() < 1e-9 && (r.y - area.y).abs() < 1e-9); + let r = l.resize_cell(id, Rect::new(7.0, 9.0, 50.0, 50.0)).unwrap(); + assert!(area.contains(&r)); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, 0.0, 1.0)).is_err()); + assert!(l.move_cell(77, 0.0, 0.0).is_err()); + l.delete_cell(id).unwrap(); + assert!(l.delete_cell(id).is_err()); + } + + #[test] + fn stacking_order() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let ids: Vec = (0..4).map(|_| l.add_cell(1.0, 1.0).unwrap()).collect(); + let order = |l: &Layout| l.cells.iter().map(|c| c.id).collect::>(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::ToFront).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[0], ids[3]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::ToBack).unwrap(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), ids); + assert!(l.restack(99, Stack::ToBack).is_err()); + } + + #[test] + fn stacking_ignores_other_pages() { + let mut l = Layout { style: Style::CustomPackage, pages: 2, ..Default::default() }; + let a = l.add_cell(1.0, 1.0).unwrap(); + let b = l.add_cell(1.0, 1.0).unwrap(); + let c = l.add_cell(1.0, 1.0).unwrap(); + l.set_cell_page(b, 1).unwrap(); + l.restack(a, Stack::ToFront).unwrap(); + // `a` goes past `c` (same page) and not necessarily past `b`. + let pos = |id| l.cells.iter().position(|x| x.id == id).unwrap(); + assert!(pos(a) > pos(c)); + } + + #[test] + fn overlap_is_allowed_by_move_and_reported() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(3.0, 3.0).unwrap(); + assert!(!l.has_overlaps()); + let r = l.cell(a).unwrap().rect; + l.move_cell(b, r.x + 1.0, r.y + 1.0).unwrap(); + assert!(l.has_overlaps()); + // Touching edges are not an overlap. + l.move_cell(b, r.right(), r.y).unwrap(); + assert!(!l.has_overlaps()); + } + + #[test] + fn auto_layout_repacks_cleanly_and_keeps_photos() { + let mut l = package(); + for (w, h) in [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (8.0, 10.0), (4.0, 6.0), (3.5, 5.0)] { + l.add_cell(w, h).unwrap(); + } + let first = l.cells[0].id; + l.set_cell_photo(first, Some(42)).unwrap(); + // Scramble positions to overlap, then auto layout. + for c in &mut l.cells { + c.rect.x = 0.25; + c.rect.y = 0.25; + c.page = 0; + } + l.pages = 1; + assert!(l.has_overlaps()); + l.auto_layout().unwrap(); + assert_clean(&l); + assert_eq!(l.cell(first).unwrap().photo, Some(42)); + assert_eq!(l.cells.len(), 6); + let again = l.clone(); + let mut l2 = again.clone(); + l2.auto_layout().unwrap(); + l.auto_layout().unwrap(); + assert_eq!(l, l2); + } + + #[test] + fn auto_layout_failure_changes_nothing() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + l.cells.push(Cell { id: 50, page: 0, rect: Rect::new(0.25, 0.25, 40.0, 40.0), photo: None }); + let before = l.clone(); + assert!(l.auto_layout().is_err()); + assert_eq!(l, before); + } + + #[test] + fn clear_and_new_page() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + assert_eq!(l.new_page().unwrap(), 1); + assert_eq!(l.pages, 2); + l.clear(); + assert_eq!((l.cells.len(), l.pages), (0, 1)); + for _ in 1..MAX_PAGES { + l.new_page().unwrap(); + } + assert!(l.new_page().is_err()); + let mut g = Layout::default(); + assert!(g.new_page().is_err()); + } + + #[test] + fn validate_catches_bad_cells() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + let ok = l.clone(); + let mut bad = ok.clone(); + bad.cells[0].rect.w = f64::NAN; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].page = 3; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].rect.x = 50.0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells.push(bad.cells[0].clone()); + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.pages = 0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.next_id = 0; + bad.validate().unwrap(); + assert!(bad.next_id > bad.cells[0].id); + assert!(serde_json::from_str::(r#"{"surprise":1}"#).is_err()); + let mut round: Layout = serde_json::from_str(&serde_json::to_string(&ok).unwrap()).unwrap(); + round.validate().unwrap(); + assert_eq!(round, ok); + } + + #[test] + fn snapping_to_cells_grid_and_margins() { + let mut l = Layout { style: Style::CustomPackage, spacing: 0.0, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(2.0, 2.0).unwrap(); + let ra = l.cell(a).unwrap().rect; + // Left edge of b near the right edge of a. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - ra.right()).abs() < 1e-9); + assert_eq!(m.y, 5.0); + // Too far: untouched. + let m = l.snap_move(b, Rect::new(ra.right() + 0.5, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.5)).abs() < 1e-9); + // Right edge snaps to the printable margin. + let area = l.printable().unwrap(); + let m = l.snap_move(b, Rect::new(area.right() - 2.0 - 0.03, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.right() - area.right()).abs() < 1e-9); + // Off leaves everything. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Off, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.04)).abs() < 1e-9); + // Grid snapping lands on multiples from the printable corner. + let m = l.snap_move(b, Rect::new(1.37, 2.62, 2.0, 2.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(((m.x - area.x) / 0.5).fract().abs() < 1e-9 || (((m.x - area.x) / 0.5).fract() - 1.0).abs() < 1e-9); + // Resize snaps the dragged corner only. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 2.1, 1.9), Snap::Grid, 0.5, 0.2).unwrap(); + assert_eq!((r.x, r.y), (5.0, 5.0)); + assert!(((r.right() - area.x) / 0.5 - ((r.right() - area.x) / 0.5).round()).abs() < 1e-9); + // Never below the minimum size. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 0.0, 0.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(r.w >= MIN_CELL_IN && r.h >= MIN_CELL_IN); + assert!(l.snap_move(99, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, 0.0, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); + } + + #[test] + fn fit_cells_follows_the_margins() { + let mut l = package(); + let id = l.add_cell(7.0, 10.0).unwrap(); + l.page.margins = Margins::uniform(1.0); + assert!(l.validate().is_err(), "the cell no longer fits"); + l.fit_cells().unwrap(); + l.validate().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!(l.printable().unwrap().contains(&r)); + assert!((r.w - 6.5).abs() < 1e-9 && (r.h - 9.0).abs() < 1e-9); + l.page.paper = "poster".into(); + assert!(l.fit_cells().is_err()); + } + + #[test] + fn cell_aspect_survives_auto_layout() { + let mut l = package(); + let id = l.add_cell(2.5, 3.5).unwrap(); + l.auto_layout().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!((r.w / r.h - 2.5 / 3.5).abs() < 1e-9 || (r.h / r.w - 2.5 / 3.5).abs() < 1e-9); + } + + #[test] + fn guides_validate() { + assert!(Guides::default().validate().is_ok()); + for bad in [f64::NAN, 0.0, -1.0, 1e9] { + assert!(Guides { grid_size: bad, ..Default::default() }.validate().is_err()); + } + } + + #[test] + fn rect_helpers() { + let a = Rect::new(0.0, 0.0, 2.0, 2.0); + assert!(a.overlaps(&Rect::new(1.0, 1.0, 2.0, 2.0))); + assert!(!a.overlaps(&Rect::new(2.0, 0.0, 1.0, 1.0))); + assert!(a.contains(&Rect::new(0.5, 0.5, 1.0, 1.0))); + assert!(!a.contains(&Rect::new(1.5, 0.5, 1.0, 1.0))); + assert_eq!(a.area(), 4.0); + } +} diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print/mod.rs new file mode 100644 index 000000000..c984cbf54 --- /dev/null +++ b/crates/engine/src/print/mod.rs @@ -0,0 +1,91 @@ +//! The Print module: page layout, composition of pages and PDF output. +//! +//! [`layout`] is the pure page model (paper, margins, cells); [`compose`] renders a layout and its +//! photos to [`PageRaster`]s; [`template`] holds the saved settings; [`output`] writes the pages +//! as a PDF. +pub mod compose; +pub mod layout; +pub mod output; +pub mod template; + +use serde::{Deserialize, Serialize}; + +/// Lowest and highest print resolution, pixels per inch. +pub const MIN_PPI: f32 = 72.0; +pub const MAX_PPI: f32 = 720.0; +/// Pages a single print may have. +pub const MAX_JOB_PAGES: usize = 100; +/// Most pixels one page may have (about 100 megapixels: A3 at 720 ppi is 60). +pub const MAX_PAGE_PIXELS: u64 = 100_000_000; +/// Most pixels a print job may hold across all its pages (6 bytes each in memory). +pub const MAX_JOB_PIXELS: u64 = 200_000_000; + +/// One composed page, ready for output: row-major interleaved RGB, 16 bits per sample, encoded in +/// Rec. 2020 ([`lightcraft_pipeline::OutputSpace::Rec2020`]; the transfer curve is that space's). +/// The page covers its paper edge to edge: `width / ppi` by `height / ppi` inches. +#[derive(Clone, Debug, PartialEq)] +pub struct PageRaster { + pub width: u32, + pub height: u32, + pub ppi: f32, + pub rgb: Vec, +} + +/// How a print job is produced (Lightroom's Print Job panel): resolution, and the colour +/// management, print adjustment, print sharpening and depth of [`crate::print_color`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PrintJob { + /// Print resolution in pixels per inch, 72 to 720. + pub ppi: f32, + /// Profile, rendering intent, black point compensation, print adjustment, print sharpening. + pub color: crate::print_color::PrintColorSettings, +} + +impl Default for PrintJob { + fn default() -> Self { + Self { ppi: 240.0, color: crate::print_color::PrintColorSettings::default() } + } +} + +impl PrintJob { + /// The resolution to render at: [`Self::ppi`] clamped to 72..=720 (240 for NaN). + pub fn effective_ppi(&self) -> f32 { + if self.ppi.is_finite() { self.ppi.clamp(MIN_PPI, MAX_PPI) } else { 240.0 } + } + + /// Rejects a resolution outside the supported range (commands and templates, which should + /// say so rather than silently clamp). + pub fn validate(&self) -> Result<(), String> { + if !(self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi)) { + return Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}")); + } + self.color.validate() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ppi_is_clamped_and_validated() { + assert_eq!(PrintJob::default().effective_ppi(), 240.0); + for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] { + assert_eq!(PrintJob { ppi: v, ..Default::default() }.effective_ppi(), e); + } + assert!(PrintJob { ppi: 300.0, ..Default::default() }.validate().is_ok()); + for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] { + assert!(PrintJob { ppi: bad, ..Default::default() }.validate().is_err()); + } + let j: PrintJob = serde_json::from_str("{}").unwrap(); + assert_eq!(j, PrintJob::default()); + assert!(serde_json::from_str::(r#"{"dpi": 1}"#).is_err()); + // the colour settings ride along, defaults filled in, and are validated with the job + let j: PrintJob = serde_json::from_str(r#"{"ppi": 300, "color": {"intent": "relative", "brightness": 20}}"#).unwrap(); + assert_eq!(j.color.intent, crate::print_color::PrintIntent::Relative); + assert!(j.color.black_point_compensation && j.validate().is_ok()); + let j: PrintJob = serde_json::from_str(r#"{"color": {"contrast": 500}}"#).unwrap(); + assert!(j.validate().is_err()); + } +} diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs new file mode 100644 index 000000000..37881fbb8 --- /dev/null +++ b/crates/engine/src/print/output.rs @@ -0,0 +1,136 @@ +//! Writes composed pages as a print-ready PDF: each page is converted to the job's colour target +//! (a printer / paper profile, or sRGB / Adobe RGB when the printer manages colour) with its +//! intent, black point compensation and print adjustment ([`crate::print_color`]), then embedded +//! covering the paper as an ICC-tagged, losslessly compressed 16-bit (or 8-bit) image. A printer +//! profile is also named as the document's output intent ([`crate::print_pdf`]). +use super::{MAX_JOB_PAGES, PageRaster, PrintJob}; +use crate::print_color::PrintColorJob; +use crate::print_pdf::ImageData; +use lightcraft_color::transfer::encode_srgb8; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_pipeline::OutputSpace; +use lightcraft_raster::Rgba8; + +/// Largest PDF produced, bytes. +const MAX_PDF_BYTES: usize = 256 * 1024 * 1024; + +/// A Rec. 2020 page as 8-bit sRGB (out-of-gamut colours are clipped). +pub fn page_to_srgb8(page: &PageRaster) -> Result { + let (w, h) = (page.width as usize, page.height as usize); + let n = w.checked_mul(h).ok_or("page too large")?; + if n == 0 || page.rgb.len() != n.checked_mul(3).ok_or("page too large")? { + return Err("a page's pixel data does not match its size".into()); + } + let trc = OutputSpace::Rec2020.trc(); + // 16-bit Rec. 2020 code → linear light, once per code value. + let lut: Vec = (0..=u16::MAX).map(|v| trc.decode(f32::from(v) / 65535.0)).collect(); + let m = REC2020.to_space(&SRGB).to_f32(); + let mut out = Rgba8::new(w, h); + for (dst, src) in out.data.iter_mut().zip(page.rgb.as_chunks::<3>().0) { + let l = src.map(|v| lut.get(usize::from(v)).copied().unwrap_or(0.0)); + let row = |r: usize| m.get(r).map_or(0.0, |m| m[0] * l[0] + m[1] * l[1] + m[2] * l[2]); + *dst = [encode_srgb8(row(0)), encode_srgb8(row(1)), encode_srgb8(row(2)), 255]; + } + Ok(out) +} + +/// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in +/// points (`pixels / ppi * 72`) with the image covering it. Each page is converted and compressed +/// in turn, so memory holds one page's device samples at a time. +pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result, String> { + let first = pages.first().ok_or("nothing to print")?; + if pages.len() > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); + } + if !(first.ppi.is_finite() && first.ppi > 0.0) { + return Err("a page has no resolution".into()); + } + let points = |px: u32| f64::from(px) / f64::from(first.ppi) * 72.0; + let size = (points(first.width), points(first.height)); + let color = PrintColorJob::new(&job.color)?; + let mut out = Vec::with_capacity(pages.len()); + let mut bytes = 0usize; + for page in pages { + if (page.width, page.height) != (first.width, first.height) { + return Err("all pages of a PDF must be the same size".into()); + } + let image = color.page_image(page.width, page.height, &page.rgb)?.deflated()?; + if let ImageData::Flate { data, .. } = &image.data { + bytes = bytes.saturating_add(data.len()); + } + if bytes > MAX_PDF_BYTES { + return Err("the PDF would exceed 256 MiB; lower the print resolution, use 8 bits or print fewer pages".into()); + } + out.push((size, image)); + } + color.pdf(out, title) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::compose::rec2020_from_srgb8; + + fn raster(w: u32, h: u32, srgb: [u8; 3]) -> PageRaster { + let c = rec2020_from_srgb8(srgb); + PageRaster { width: w, height: h, ppi: 100.0, rgb: (0..w * h).flat_map(|_| c).collect() } + } + + #[test] + fn colours_survive_the_round_trip() { + for c in [[255u8, 255, 255], [0, 0, 0], [128, 128, 128], [200, 30, 30], [10, 200, 90]] { + let px = page_to_srgb8(&raster(2, 2, c)).unwrap().data[0]; + for k in 0..3 { + assert!(px[k].abs_diff(c[k]) <= 1, "{c:?} -> {px:?}"); + } + } + assert!(page_to_srgb8(&PageRaster { width: 2, height: 2, ppi: 100.0, rgb: vec![0; 5] }).is_err()); + assert!(page_to_srgb8(&PageRaster { width: 0, height: 2, ppi: 100.0, rgb: vec![] }).is_err()); + } + + #[test] + fn pdf_pages_have_the_paper_size() { + let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])]; + let bytes = write(&pages, &PrintJob::default(), "t").unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-")); + assert!(text.contains("/Count 2")); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt"); + assert!(text.contains("/Width 425 /Height 550")); + // managed by the printer: sRGB, 16 bits, tagged, no output intent + assert!(text.contains("/ICCBased") && text.contains("/BitsPerComponent 16") && !text.contains("/OutputIntents")); + assert!(write(&[], &PrintJob::default(), "t").is_err()); + let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; + assert!(write(&mixed, &PrintJob::default(), "t").is_err()); + let bad = PageRaster { ppi: f32::NAN, ..raster(4, 4, [0; 3]) }; + assert!(write(&[bad], &PrintJob::default(), "t").is_err()); + let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) }; + assert!(write(&[zero], &PrintJob::default(), "t").is_err()); + } + + #[test] + fn a_printer_profile_is_converted_to_and_named_as_the_output_intent() { + use crate::print_color::{PrintColorSettings, PrintIntent, PrintProfile}; + let dir = std::env::temp_dir().join(format!("lc-print-output-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Lustre Paper.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let color = + PrintColorSettings { profile: PrintProfile::Icc { path: icc }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() }; + let job = PrintJob { color, ..Default::default() }; + let bytes = write(&[raster(20, 30, [200, 30, 30])], &job, "Lustre").unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/OutputIntents") && text.contains("/DestOutputProfile"), "{}", &text[..text.len().min(500)]); + assert!(text.contains("/BitsPerComponent 8") && text.contains("/Title (Lustre)")); + // a profile that isn't one fails the print and says which file + std::fs::write(dir.join("Junk.icc"), [7u8; 200]).unwrap(); + let junk = PrintJob { + color: PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("Junk.icc") }, ..Default::default() }, + ..Default::default() + }; + let err = write(&[raster(2, 2, [0; 3])], &junk, "t").unwrap_err(); + assert!(err.contains("Junk"), "{err}"); + } +} diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs new file mode 100644 index 000000000..2465c123a --- /dev/null +++ b/crates/engine/src/print/template.rs @@ -0,0 +1,124 @@ +//! The settings a print is made from, and (with the saved-template commands) what a print +//! template stores. +use super::PrintJob; +use super::compose::{ImageSettings, PageStyle}; +use super::layout::{Guides, Layout}; +use serde::{Deserialize, Serialize}; + +/// Layout, image settings, page, guides and job: the whole Print module state. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PrintSettings { + pub layout: Layout, + pub image: ImageSettings, + pub page: PageStyle, + pub guides: Guides, + pub job: PrintJob, +} + +impl PrintSettings { + /// Checks every part; run on anything that did not come from the setters. + pub fn validate(&mut self) -> Result<(), String> { + self.layout.validate()?; + self.image.validate()?; + self.guides.validate()?; + self.job.validate() + } +} + +/// Longest template name, characters. +pub const MAX_NAME: usize = 80; +/// Most templates kept. +pub const MAX_TEMPLATES: usize = 200; + +/// A saved print template: the layout, image settings, page and job of a print, without the +/// photos assigned to cells and without the guides (a view preference). The settings are kept as +/// JSON so a template written by another version never makes the preferences file unreadable; +/// [`PrintTemplate::settings`] checks them when they are used. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintTemplate { + pub name: String, + pub settings: serde_json::Value, +} + +/// A template name: trimmed, 1 to [`MAX_NAME`] characters, no control characters. +pub fn clean_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) { + return Err(format!("a template name is 1 to {MAX_NAME} characters without control characters")); + } + Ok(name.to_string()) +} + +impl PrintTemplate { + /// A template of `settings` (validated first) named `name`. + pub fn new(name: &str, settings: &PrintSettings) -> Result { + let mut s = settings.clone(); + s.validate()?; + for c in &mut s.layout.cells { + c.photo = None; + } + let mut value = serde_json::to_value(&s).map_err(|e| e.to_string())?; + if let Some(o) = value.as_object_mut() { + o.remove("guides"); + } + Ok(Self { name: clean_name(name)?, settings: value }) + } + + /// The settings, checked: unknown fields, hostile numbers and inconsistent layouts are errors. + /// `guides` are those of the print the template is applied to. + pub fn settings(&self, guides: &Guides) -> Result { + let mut s: PrintSettings = serde_json::from_value(self.settings.clone()).map_err(|e| format!("template `{}`: {e}", self.name))?; + s.guides = *guides; + s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?; + Ok(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::layout::Style; + use serde_json::json; + + #[test] + fn a_template_keeps_the_look_not_the_photos_or_guides() { + let mut s = PrintSettings::default(); + s.layout.style = Style::CustomPackage; + let id = s.layout.add_cell(3.0, 2.0).unwrap(); + s.layout.set_cell_photo(id, Some(77)).unwrap(); + s.image.zoom_to_fill = true; + s.page.background = [10, 20, 30]; + s.job.ppi = 300.0; + s.guides.grid_size = 1.0; + let t = PrintTemplate::new(" Proof sheet ", &s).unwrap(); + assert_eq!(t.name, "Proof sheet"); + assert!(t.settings.get("guides").is_none()); + let back = t.settings(&Guides::default()).unwrap(); + assert_eq!(back.layout.cells.len(), 1); + assert_eq!(back.layout.cells[0].photo, None); + assert!(back.image.zoom_to_fill); + assert_eq!((back.page.background, back.job.ppi), ([10, 20, 30], 300.0)); + assert_eq!(back.guides, Guides::default(), "the guides are the caller's"); + } + + #[test] + fn hostile_templates_are_rejected() { + for name in ["", " ", "a\nb", &"x".repeat(MAX_NAME + 1)] { + assert!(PrintTemplate::new(name, &PrintSettings::default()).is_err(), "{name:?}"); + } + let bad = |v: serde_json::Value| PrintTemplate { name: "t".into(), settings: v }.settings(&Guides::default()).is_err(); + assert!(bad(json!("text"))); + assert!(bad(json!({"surprise": 1}))); + assert!(bad(json!({"job": {"ppi": 1e30}}))); + assert!(bad(json!({"layout": {"pages": 0}}))); + assert!(bad(json!({"layout": {"page": {"paper": "custom", "customWidth": -3}}}))); + assert!(bad(json!({"layout": {"cells": [{"id": 1, "rect": {"x": 0, "y": 0, "w": 1e9, "h": 1}}]}}))); + assert!(bad(json!({"image": {"borderWidth": -1}}))); + assert!(!bad(json!({}))); + let mut s = PrintSettings::default(); + s.job.ppi = f32::NAN; + assert!(PrintTemplate::new("t", &s).is_err()); + } +} diff --git a/crates/engine/src/print_color.rs b/crates/engine/src/print_color.rs new file mode 100644 index 000000000..562b75129 --- /dev/null +++ b/crates/engine/src/print_color.rs @@ -0,0 +1,386 @@ +//! The print job's colour: which profile composed pages are converted to and how, print sharpening +//! for the paper, and the print-ready PDF they end up in. +//! +//! - **Profile**: "Managed by Printer" sends the page in sRGB or Adobe RGB with that profile +//! embedded (the driver converts); a printer / paper ICC profile converts the page to the device's +//! own values here and names the profile as the document's output intent, so the driver must not +//! convert again. +//! - **Intent, black point compensation, print adjustment**: [`lightcraft_codecs::print`]. +//! - **Print sharpening** (Low / Standard / High for Matte or Glossy paper): each photo is sharpened +//! at its print size before it goes on the page, with the export sharpener. +//! - **Depth**: 16 bits per component by default (no banding from the profile conversion); 8 bits +//! for smaller files. +//! +//! Pages arrive as 16-bit RGB in the `Rec2020` output encoding, the composed page of the layout. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use lightcraft_codecs::display::MAX_PROFILE_BYTES; +use lightcraft_codecs::print::{PrintColor, PrintTarget, PrintTransform}; +use lightcraft_codecs::{NamedSpace, proof::ProofIntent}; +use serde::{Deserialize, Serialize}; + +use crate::export::{DeepImage, DeepSamples, OutputSpace, SharpenAmount, SharpenFor, output_sharpen_deep}; +use crate::print_pdf::{ImageData, OutputIntent, PdfImage, PdfInfo, PdfPage, Placement}; + +/// The colour space a printer that manages colour receives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ManagedSpace { + #[default] + Srgb, + AdobeRgb, +} + +/// Where the print's colours go. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PrintProfile { + /// Managed by Printer: the page goes in this space and the driver converts it. + Managed { space: ManagedSpace }, + /// A printer / paper profile (an `.icc` / `.icm` file): converted here. + Icc { path: PathBuf }, +} + +impl Default for PrintProfile { + fn default() -> Self { + PrintProfile::Managed { space: ManagedSpace::Srgb } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintIntent { + #[default] + Perceptual, + Relative, +} + +/// Print sharpening strength (Off leaves the photos as rendered). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintSharpen { + Off, + Low, + #[default] + Standard, + High, +} + +/// The paper print sharpening is tuned for. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintMedia { + Matte, + #[default] + Glossy, +} + +/// The print job's colour settings, as saved with print templates. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintColorSettings { + pub profile: PrintProfile, + pub intent: PrintIntent, + /// Map the page's black to the paper's darkest (relative colorimetric). + pub black_point_compensation: bool, + /// Print adjustment, −100..100. + pub brightness: f32, + /// Print adjustment, −100..100. + pub contrast: f32, + pub sharpen: PrintSharpen, + pub media: PrintMedia, + /// 16 bits per component in the PDF (else 8). + pub sixteen_bit: bool, +} + +impl Default for PrintColorSettings { + fn default() -> Self { + PrintColorSettings { + profile: PrintProfile::default(), + intent: PrintIntent::default(), + black_point_compensation: true, + brightness: 0.0, + contrast: 0.0, + sharpen: PrintSharpen::default(), + media: PrintMedia::default(), + sixteen_bit: true, + } + } +} + +impl PrintColorSettings { + /// Refuse settings a command or template shouldn't carry (out-of-range adjustments, an empty + /// profile path); whether the profile file is usable is checked when the job is built. + pub fn validate(&self) -> Result<(), String> { + for (name, v) in [("brightness", self.brightness), ("contrast", self.contrast)] { + if !(v.is_finite() && (-100.0..=100.0).contains(&v)) { + return Err(format!("{name} must be a number from -100 to 100")); + } + } + if let PrintProfile::Icc { path } = &self.profile + && path.as_os_str().is_empty() + { + return Err("the printer profile needs a path".into()); + } + Ok(()) + } + + /// Sharpen one photo for the paper, at the size it prints (before it goes on the page). + pub fn sharpen_photo(&self, photo: &mut DeepImage) { + let amount = match self.sharpen { + PrintSharpen::Off => return, + PrintSharpen::Low => SharpenAmount::Low, + PrintSharpen::Standard => SharpenAmount::Standard, + PrintSharpen::High => SharpenAmount::High, + }; + let target = match self.media { + PrintMedia::Matte => SharpenFor::Matte, + PrintMedia::Glossy => SharpenFor::Glossy, + }; + output_sharpen_deep(photo, target, amount); + } +} + +/// Read a printer profile, refusing files that can't be one (the parse happens later). +fn read_profile(path: &Path) -> Result, String> { + let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + let len = std::fs::metadata(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))?.len(); + if len > MAX_PROFILE_BYTES as u64 { + return Err(format!("the printer profile {shown} is too large")); + } + std::fs::read(path).map_err(|e| format!("can't read the printer profile {shown}: {e}")) +} + +/// A print job's colour, ready to apply: the conversion built once and what the PDF needs to know. +#[derive(Debug)] +pub struct PrintColorJob { + pub settings: PrintColorSettings, + transform: PrintTransform, + /// The printing condition named in the PDF (only for a printer profile). + intent: Option, +} + +impl PrintColorJob { + /// Build the job's conversion. Errors name the profile and what's wrong with it. + pub fn new(settings: &PrintColorSettings) -> Result { + let s = settings.clone(); + let color = PrintColor { + intent: match s.intent { + PrintIntent::Perceptual => ProofIntent::Perceptual, + PrintIntent::Relative => ProofIntent::Relative, + }, + black_point_compensation: s.black_point_compensation, + brightness: s.brightness, + contrast: s.contrast, + }; + let (target, name) = match &s.profile { + PrintProfile::Managed { space } => { + (PrintTarget::Managed(if *space == ManagedSpace::AdobeRgb { NamedSpace::AdobeRgb } else { NamedSpace::Srgb }), None) + } + PrintProfile::Icc { path } => { + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + (PrintTarget::Profile(Arc::new(read_profile(path)?)), Some(name)) + } + }; + let transform = PrintTransform::new(&target, &color).map_err(|e| match &name { + Some(n) => format!("the printer profile {n}: {e}"), + None => e, + })?; + let intent = name.map(|file| OutputIntent { + icc: Arc::clone(transform.icc()), + channels: transform.channels() as u8, + condition: transform.description().map(str::to_owned).unwrap_or(file), + }); + Ok(PrintColorJob { settings: s, transform, intent }) + } + + /// Sharpen one photo for the paper ([`PrintColorSettings::sharpen_photo`]). + pub fn sharpen_photo(&self, photo: &mut DeepImage) { + self.settings.sharpen_photo(photo); + } + + /// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers + /// it, in the target's colours. + pub fn page_image(&self, width: u32, height: u32, rgb: &[u16]) -> Result { + let want = u64::from(width).checked_mul(u64::from(height)).and_then(|p| p.checked_mul(3)); + if width == 0 || height == 0 || want != Some(rgb.len() as u64) { + return Err(format!("a page of {width} × {height} pixels can't be made from {} samples", rgb.len())); + } + let device = self.transform.apply(rgb)?; + let channels = self.transform.channels() as u8; + let icc = Some(Arc::clone(self.transform.icc())); + Ok(if self.settings.sixteen_bit { + PdfImage::from_u16(width, height, channels, &device, icc) + } else { + let samples = device.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect(); + PdfImage { width, height, channels, data: ImageData::Raw { bits: 8, samples }, icc } + }) + } + + /// The print-ready document: each page image covers its page (`size` in points). + pub fn pdf(&self, pages: Vec<((f64, f64), PdfImage)>, title: &str) -> Result, String> { + let pages: Vec = pages + .into_iter() + .map(|((w, h), image)| PdfPage { width: w, height: h, images: vec![(image, Placement { x: 0.0, y: 0.0, width: w, height: h })] }) + .collect(); + let title = (!title.trim().is_empty()).then(|| title.to_owned()); + crate::print_pdf::write(&pages, &PdfInfo { title, output_intent: self.intent.clone() }) + } + + /// Samples per pixel the pages are converted to (3 RGB, 4 CMYK, 1 grey). + pub fn channels(&self) -> usize { + self.transform.channels() + } + + /// The profile's description (or file name) for the job's summary. + pub fn profile_name(&self) -> Option<&str> { + self.intent.as_ref().map(|i| i.condition.as_str()).or(self.transform.description()) + } +} + +/// A photo rendered for the page as the layout gets it from the export renderer. +pub fn is_print_ready(photo: &DeepImage) -> bool { + photo.space == OutputSpace::Rec2020 && matches!(photo.samples, DeepSamples::U16(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::print::print_adjust; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-print-color-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A small, flat page of one 16-bit colour. + fn page(w: u32, h: u32, v: [u16; 3]) -> Vec { + (0..w * h).flat_map(|_| v).collect() + } + + #[test] + fn settings_default_and_round_trip() { + let d = PrintColorSettings::default(); + assert_eq!(d.profile, PrintProfile::Managed { space: ManagedSpace::Srgb }); + assert!(d.black_point_compensation && d.sixteen_bit); + let s = PrintColorSettings { + profile: PrintProfile::Icc { path: "/Library/ColorSync/Profiles/Paper.icc".into() }, + intent: PrintIntent::Relative, + brightness: 12.0, + sharpen: PrintSharpen::High, + media: PrintMedia::Matte, + ..d.clone() + }; + let json = serde_json::to_value(&s).unwrap(); + assert_eq!(json["profile"]["kind"], "icc"); + assert_eq!(json["intent"], "relative"); + assert_eq!(serde_json::from_value::(json).unwrap(), s); + // partial and unknown-free JSON fills in the defaults + let partial: PrintColorSettings = serde_json::from_str(r#"{"intent":"relative"}"#).unwrap(); + assert_eq!(partial, PrintColorSettings { intent: PrintIntent::Relative, ..d }); + assert!(serde_json::from_str::(r#"{"profile":{"kind":"cmyk"}}"#).is_err()); + } + + #[test] + fn managed_srgb_page_becomes_a_16_bit_srgb_pdf_without_an_output_intent() { + let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); + let img = job.page_image(4, 2, &page(4, 2, [65535, 65535, 65535])).unwrap(); + assert_eq!((img.channels, img.width, img.height), (3, 4, 2)); + let ImageData::Raw { bits: 16, samples } = &img.data else { panic!("16-bit samples") }; + assert!(samples.chunks(2).all(|b| u16::from_be_bytes([b[0], b[1]]) > 65400), "white stays white"); + let pdf = job.pdf(vec![((288.0, 144.0), img)], "Prints").unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/ICCBased") && s.contains("/BitsPerComponent 16") && s.contains("/MediaBox [0 0 288 144]")); + assert!(!s.contains("/OutputIntents"), "the printer converts: no output intent"); + assert!(s.contains("/Title (Prints)")); + } + + #[test] + fn a_printer_profile_converts_and_becomes_the_output_intent() { + let dir = scratch("icc"); + let path = dir.join("Studio Glossy.icc"); + std::fs::write(&path, lightcraft_codecs::icc::write_named(NamedSpace::AdobeRgb)).unwrap(); + let settings = + PrintColorSettings { profile: PrintProfile::Icc { path }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() }; + let job = PrintColorJob::new(&settings).unwrap(); + assert_eq!(job.channels(), 3); + let img = job.page_image(2, 2, &page(2, 2, [0, 65535, 0])).unwrap(); + assert!(matches!(&img.data, ImageData::Raw { bits: 8, .. })); + let pdf = job.pdf(vec![((100.0, 100.0), img)], "").unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/OutputIntents") && s.contains("/BitsPerComponent 8"), "{}", &s[..s.len().min(600)]); + assert!(!s.contains("/Title"), "an empty title is left out"); + assert!(job.profile_name().is_some()); + } + + #[test] + fn profile_errors_name_the_file() { + let dir = scratch("bad"); + let garbage = dir.join("Broken Paper.icc"); + std::fs::write(&garbage, [0u8; 300]).unwrap(); + let err = PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: garbage }, ..Default::default() }).unwrap_err(); + assert!(err.contains("Broken Paper"), "{err}"); + let missing = + PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("nope.icc") }, ..Default::default() }).unwrap_err(); + assert!(missing.contains("nope.icc"), "{missing}"); + } + + #[test] + fn print_adjustment_reaches_the_page() { + let mid = [32768u16; 3]; + let plain = PrintColorJob::new(&PrintColorSettings::default()).unwrap().page_image(1, 1, &mid).unwrap(); + let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap(); + let first = |i: &PdfImage| match &i.data { + ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]), + ImageData::Jpeg(_) | ImageData::Flate { .. } => 0, + }; + assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain)); + assert!(print_adjust(0.5, 60.0, 0.0) > 0.5); + } + + #[test] + fn print_sharpening_follows_the_setting() { + // a hard vertical edge in a 16-bit Rec2020 photo + let (w, h) = (8usize, 4usize); + let samples: Vec = (0..w * h).flat_map(|i| if i % w < w / 2 { [20000u16; 3] } else { [40000u16; 3] }).collect(); + let photo = DeepImage { width: w, height: h, space: OutputSpace::Rec2020, samples: DeepSamples::U16(samples) }; + assert!(is_print_ready(&photo)); + let sharpened = |s: PrintSharpen| { + let job = PrintColorJob::new(&PrintColorSettings { sharpen: s, media: PrintMedia::Matte, ..Default::default() }).unwrap(); + let mut p = photo.clone(); + job.sharpen_photo(&mut p); + p + }; + assert_eq!(sharpened(PrintSharpen::Off), photo); + let edge = |p: &DeepImage| match &p.samples { + DeepSamples::U16(v) => (v[(w / 2 - 1) * 3], v[(w / 2) * 3]), + DeepSamples::F32(_) => (0, 0), + }; + let (dark, light) = edge(&sharpened(PrintSharpen::High)); + assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}"); + } + + #[test] + fn validation_refuses_out_of_range_settings() { + assert!(PrintColorSettings::default().validate().is_ok()); + for (b, c) in [(101.0, 0.0), (0.0, -100.5), (f32::NAN, 0.0), (0.0, f32::INFINITY)] { + assert!(PrintColorSettings { brightness: b, contrast: c, ..Default::default() }.validate().is_err(), "{b} {c}"); + } + assert!(PrintColorSettings { profile: PrintProfile::Icc { path: PathBuf::new() }, ..Default::default() }.validate().is_err()); + assert!(PrintColorSettings { brightness: -100.0, contrast: 100.0, ..Default::default() }.validate().is_ok()); + } + + #[test] + fn hostile_pages_are_errors() { + let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); + assert!(job.page_image(0, 1, &[]).is_err()); + assert!(job.page_image(2, 2, &[0; 11]).is_err()); + assert!(job.page_image(u32::MAX, u32::MAX, &[0; 3]).is_err()); + let img = job.page_image(1, 1, &[0; 3]).unwrap(); + assert!(job.pdf(vec![((f64::NAN, 10.0), img)], "x").is_err()); + assert!(job.pdf(vec![], "x").is_err()); + } +} diff --git a/crates/engine/src/print_pdf.rs b/crates/engine/src/print_pdf.rs new file mode 100644 index 000000000..abce4061e --- /dev/null +++ b/crates/engine/src/print_pdf.rs @@ -0,0 +1,520 @@ +//! A small PDF writer for print output: pages of a given size in points, each with raster images +//! placed on it, tagged with their ICC profile (`ICCBased`) so the numbers mean the same colour in +//! every reader and RIP, Flate-compressed (lossless) at 8 or 16 bits per component, or carried as +//! JPEG (`DCTDecode`) where small files matter more than exact samples. Optionally an output intent: +//! the printing condition (the printer / paper profile) the file was made for, as PDF/X readers +//! expect it in the catalog. Profiles shared by several images are written once. +//! +//! The file is PDF 1.6 (16-bit images need 1.5): classic cross-reference table, no object streams, +//! deterministic (no dates or IDs), so the same pages always give the same bytes. +//! +//! Sources: ISO 32000-1:2008 (§7.3 objects, §7.5 file structure, §8.6.5.5 ICCBased colour spaces, +//! §8.9 images, §14.11.5 output intents). Own design. + +use std::collections::HashMap; +use std::sync::Arc; + +/// Pages per document: a print job beyond this is a mistake, not a book. +pub const MAX_PAGES: usize = 1000; +/// Images per page. +pub const MAX_IMAGES_PER_PAGE: usize = 256; +/// Largest page side in points (200 inches): more than any printer's roll. +pub const MAX_PAGE_POINTS: f64 = 14_400.0; +/// Largest image, in pixels per side. +pub const MAX_IMAGE_SIDE: u32 = 65_535; + +/// How an image's samples are stored. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageData { + /// Interleaved samples, `bits` (8 or 16) per component, 16-bit values big-endian: compressed + /// losslessly in the file. + Raw { bits: u8, samples: Vec }, + /// A baseline JPEG stream, copied into the file as it is. + Jpeg(Vec), + /// Samples already compressed with zlib (see [`PdfImage::deflated`]), `bits` per component. + Flate { bits: u8, data: Vec }, +} + +/// A raster image and the colour space its samples are in. +#[derive(Clone, Debug, PartialEq)] +pub struct PdfImage { + pub width: u32, + pub height: u32, + /// Components per pixel: 1 (grey), 3 (RGB) or 4 (CMYK). + pub channels: u8, + pub data: ImageData, + /// The ICC profile the samples are in; without one the image is device colour. + pub icc: Option>>, +} + +impl PdfImage { + /// An image from interleaved 16-bit samples. + pub fn from_u16(width: u32, height: u32, channels: u8, samples: &[u16], icc: Option>>) -> PdfImage { + let bytes = samples.iter().flat_map(|v| v.to_be_bytes()).collect(); + PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc } + } + + /// The same image with its samples compressed now (checked first), so a long print doesn't + /// hold every page's uncompressed samples until the file is written. + pub fn deflated(self) -> Result { + self.check()?; + let data = match self.data { + ImageData::Raw { bits, samples } => ImageData::Flate { bits, data: deflate(&samples) }, + other => other, + }; + Ok(PdfImage { data, ..self }) + } + + fn check(&self) -> Result<(), String> { + if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE { + return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height)); + } + if !matches!(self.channels, 1 | 3 | 4) { + return Err(format!("images have 1, 3 or 4 components, not {}", self.channels)); + } + match &self.data { + ImageData::Raw { bits, samples } => { + if !matches!(bits, 8 | 16) { + return Err(format!("images have 8 or 16 bits per component, not {bits}")); + } + let want = u64::from(self.width) * u64::from(self.height) * u64::from(self.channels) * u64::from(bits / 8); + if samples.len() as u64 != want { + return Err(format!("the image needs {want} bytes of samples, not {}", samples.len())); + } + } + ImageData::Jpeg(data) => { + if !data.starts_with(&[0xFF, 0xD8]) { + return Err("the image isn't a JPEG stream".into()); + } + } + ImageData::Flate { bits, data } => { + if !matches!(bits, 8 | 16) || data.is_empty() { + return Err("a compressed image has 8 or 16 bits per component and some data".into()); + } + } + } + Ok(()) + } +} + +/// Where an image goes on its page, in points from the page's bottom-left corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// One page: its size in points and the images on it, drawn in order (later ones on top). +#[derive(Clone, Debug, PartialEq)] +pub struct PdfPage { + pub width: f64, + pub height: f64, + pub images: Vec<(PdfImage, Placement)>, +} + +/// The printing condition the document is made for (PDF/X-style output intent). +#[derive(Clone, Debug, PartialEq)] +pub struct OutputIntent { + /// The printer / paper profile. + pub icc: Arc>, + /// Its components: 1, 3 or 4. + pub channels: u8, + /// A name for the condition (the profile's description). + pub condition: String, +} + +/// Document-level settings. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PdfInfo { + pub title: Option, + pub output_intent: Option, +} + +fn finite(v: f64) -> bool { + v.is_finite() +} + +/// A number as PDF writes it: up to four decimals, no exponent, no trailing zeros. +fn num(v: f64) -> String { + let s = format!("{:.4}", if finite(v) { v } else { 0.0 }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" || s.is_empty() { "0".into() } else { s.into() } +} + +/// A text string: plain ASCII as a literal string, anything else as UTF-16BE with a byte order mark. +fn text(s: &str) -> String { + if s.bytes().all(|b| (0x20..0x7f).contains(&b)) { + let mut out = String::with_capacity(s.len() + 2); + out.push('('); + for c in s.chars() { + if matches!(c, '(' | ')' | '\\') { + out.push('\\'); + } + out.push(c); + } + out.push(')'); + out + } else { + let mut out = String::from("'); + out + } +} + +struct Writer { + out: Vec, + offsets: Vec, +} + +impl Writer { + /// Reserve an object number (filled later by [`Writer::object`]). + fn reserve(&mut self) -> usize { + self.offsets.push(0); + self.offsets.len() + } + + fn object(&mut self, id: usize, dict: &str, stream: Option<&[u8]>) { + if let Some(slot) = self.offsets.get_mut(id.wrapping_sub(1)) { + *slot = self.out.len(); + } + self.out.extend_from_slice(format!("{id} 0 obj\n{dict}").as_bytes()); + if let Some(data) = stream { + self.out.extend_from_slice(b"\nstream\n"); + self.out.extend_from_slice(data); + self.out.extend_from_slice(b"\nendstream"); + } + self.out.extend_from_slice(b"\nendobj\n"); + } +} + +fn deflate(data: &[u8]) -> Vec { + miniz_oxide::deflate::compress_to_vec_zlib(data, 6) +} + +/// Write a document. Errors name the first page or image that can't be written. +pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result, String> { + if pages.is_empty() { + return Err("there are no pages to write".into()); + } + if pages.len() > MAX_PAGES { + return Err(format!("a document holds at most {MAX_PAGES} pages")); + } + for (n, page) in pages.iter().enumerate() { + let n = n + 1; + if !(finite(page.width) && finite(page.height)) + || page.width <= 0.0 + || page.height <= 0.0 + || page.width > MAX_PAGE_POINTS + || page.height > MAX_PAGE_POINTS + { + return Err(format!("page {n} has no usable size")); + } + if page.images.len() > MAX_IMAGES_PER_PAGE { + return Err(format!("page {n} has more than {MAX_IMAGES_PER_PAGE} images")); + } + for (image, at) in &page.images { + image.check().map_err(|e| format!("page {n}: {e}"))?; + if ![at.x, at.y, at.width, at.height].into_iter().all(finite) || at.width <= 0.0 || at.height <= 0.0 { + return Err(format!("page {n}: an image has no usable placement")); + } + } + } + if let Some(intent) = &info.output_intent + && !matches!(intent.channels, 1 | 3 | 4) + { + return Err("the output intent's profile has 1, 3 or 4 components".into()); + } + + let mut w = Writer { out: Vec::new(), offsets: Vec::new() }; + w.out.extend_from_slice(b"%PDF-1.6\n%\xE2\xE3\xCF\xD3\n"); + let catalog = w.reserve(); + let pages_id = w.reserve(); + let info_id = w.reserve(); + + // ICC profiles, each written once (keyed by content) with its component count + let mut profiles: HashMap, usize> = HashMap::new(); + let mut profile = |w: &mut Writer, icc: &Arc>, n: u8| -> usize { + if let Some(&id) = profiles.get(icc.as_slice()) { + return id; + } + let id = w.reserve(); + let data = deflate(icc); + let alternate = match n { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + w.object(id, &format!("<< /N {n} /Alternate {alternate} /Filter /FlateDecode /Length {} >>", data.len()), Some(&data)); + profiles.insert(icc.to_vec(), id); + id + }; + + let intent_id = info.output_intent.as_ref().map(|intent| profile(&mut w, &intent.icc, intent.channels)); + + let mut kids = Vec::with_capacity(pages.len()); + for page in pages { + let page_id = w.reserve(); + let mut xobjects = String::new(); + let mut content = String::new(); + for (k, (image, at)) in page.images.iter().enumerate() { + let space = match &image.icc { + Some(icc) => format!("[/ICCBased {} 0 R]", profile(&mut w, icc, image.channels)), + None => match image.channels { + 1 => "/DeviceGray".into(), + 4 => "/DeviceCMYK".into(), + _ => "/DeviceRGB".into(), + }, + }; + let id = w.reserve(); + let (bits, filter, data): (u8, &str, std::borrow::Cow<[u8]>) = match &image.data { + ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples).into()), + ImageData::Flate { bits, data } => (*bits, "/FlateDecode", data.as_slice().into()), + ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.as_slice().into()), + }; + w.object( + id, + &format!( + "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {space} /BitsPerComponent {bits} /Filter {filter} /Length {} >>", + image.width, + image.height, + data.len() + ), + Some(&data), + ); + xobjects.push_str(&format!("/Im{k} {id} 0 R ")); + content.push_str(&format!("q {} 0 0 {} {} {} cm /Im{k} Do Q\n", num(at.width), num(at.height), num(at.x), num(at.y))); + } + let content_id = w.reserve(); + let stream = deflate(content.as_bytes()); + w.object(content_id, &format!("<< /Filter /FlateDecode /Length {} >>", stream.len()), Some(&stream)); + w.object( + page_id, + &format!( + "<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Resources << /XObject << {xobjects}>> >> /Contents {content_id} 0 R >>", + num(page.width), + num(page.height) + ), + None, + ); + kids.push(format!("{page_id} 0 R")); + } + w.object(pages_id, &format!("<< /Type /Pages /Count {} /Kids [{}] >>", kids.len(), kids.join(" ")), None); + + let intents = match (&info.output_intent, intent_id) { + (Some(intent), Some(id)) => format!( + " /OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /Info {} /DestOutputProfile {id} 0 R >>]", + text(&intent.condition) + ), + _ => String::new(), + }; + w.object(catalog, &format!("<< /Type /Catalog /Pages {pages_id} 0 R{intents} >>"), None); + let title = info.title.as_deref().map(|t| format!(" /Title {}", text(t))).unwrap_or_default(); + w.object(info_id, &format!("<< /Producer (LightCraft){title} >>"), None); + + let xref = w.out.len(); + let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", w.offsets.len() + 1); + for offset in &w.offsets { + table.push_str(&format!("{offset:010} 00000 n \n")); + } + table.push_str(&format!("trailer\n<< /Size {} /Root {catalog} 0 R /Info {info_id} 0 R >>\nstartxref\n{xref}\n%%EOF\n", w.offsets.len() + 1)); + w.out.extend_from_slice(table.as_bytes()); + Ok(w.out) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::NamedSpace; + + /// The body of object `id` (dictionary and raw stream bytes), found through the xref table. + fn object(pdf: &[u8], id: usize) -> (String, Vec) { + // byte offsets: the streams are binary, so search the bytes, not a lossy string + let start = pdf.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10; + let tail = std::str::from_utf8(&pdf[start..]).unwrap(); + let xref: usize = tail.lines().next().unwrap().trim().parse().unwrap(); + let table = std::str::from_utf8(&pdf[xref..]).unwrap(); + let line = table.lines().nth(2 + id).unwrap(); + let offset: usize = line[..10].parse().unwrap(); + let head = format!("{id} 0 obj\n"); + assert_eq!(&pdf[offset..offset + head.len()], head.as_bytes(), "xref entry for {id}"); + let rest = &pdf[offset + head.len()..]; + let end = rest.windows(7).position(|w| w == b"\nendobj").unwrap(); + let body = &rest[..end]; + match body.windows(8).position(|w| w == b"\nstream\n") { + Some(p) => { + let data = &body[p + 8..body.len() - "\nendstream".len()]; + (String::from_utf8_lossy(&body[..p]).into_owned(), data.to_vec()) + } + None => (String::from_utf8_lossy(body).into_owned(), vec![]), + } + } + + fn inflate(data: &[u8]) -> Vec { + miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap() + } + + /// Object ids of every image XObject, in file order. + fn images(pdf: &[u8]) -> Vec { + let s = String::from_utf8_lossy(pdf); + s.match_indices(" 0 obj\n<< /Type /XObject /Subtype /Image").map(|(i, _)| s[..i].rsplit('\n').next().unwrap().parse().unwrap()).collect() + } + + fn reference(dict: &str, key: &str) -> usize { + let at = dict.find(key).unwrap() + key.len(); + dict[at..].trim_start().split(' ').next().unwrap().parse().unwrap() + } + + fn srgb() -> Arc> { + Arc::new(lightcraft_codecs::icc::write_named(NamedSpace::Srgb)) + } + + fn page(images: Vec<(PdfImage, Placement)>) -> PdfPage { + PdfPage { width: 612.0, height: 792.0, images } + } + + fn full(w: f64, h: f64) -> Placement { + Placement { x: 0.0, y: 0.0, width: w, height: h } + } + + #[test] + fn a_16_bit_icc_image_round_trips() { + let samples: Vec = (0..2 * 3 * 3).map(|i| (i * 3851) as u16).collect(); + let img = PdfImage::from_u16(3, 2, 3, &samples, Some(srgb())); + let pdf = write(&[page(vec![(img, full(612.0, 792.0))])], &PdfInfo::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF-1.6\n")); + assert!(pdf.ends_with(b"%%EOF\n")); + let ids = images(&pdf); + assert_eq!(ids.len(), 1); + let (dict, data) = object(&pdf, ids[0]); + assert!(dict.contains("/Width 3 /Height 2") && dict.contains("/BitsPerComponent 16") && dict.contains("/FlateDecode"), "{dict}"); + let back: Vec = inflate(&data).chunks(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect(); + assert_eq!(back, samples); + let (profile, icc) = object(&pdf, reference(&dict, "/ICCBased")); + assert!(profile.contains("/N 3") && profile.contains("/Alternate /DeviceRGB"), "{profile}"); + assert_eq!(inflate(&icc), *srgb()); + assert_eq!(lightcraft_codecs::icc::parse(&inflate(&icc)).and_then(|i| i.named), Some(NamedSpace::Srgb)); + } + + #[test] + fn pages_sizes_placements_and_a_shared_profile() { + let px = |v: u16| PdfImage::from_u16(1, 1, 3, &[v, v, v], Some(srgb())); + let at = Placement { x: 36.0, y: 72.5, width: 288.0, height: 216.0 }; + let pages = [page(vec![(px(1), at), (px(2), full(612.0, 792.0))]), PdfPage { width: 841.89, height: 595.28, images: vec![(px(3), at)] }]; + let pdf = write(&pages, &PdfInfo::default()).unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/Type /Pages /Count 2"), "two pages"); + assert!(s.contains("/MediaBox [0 0 612 792]") && s.contains("/MediaBox [0 0 841.89 595.28]")); + // the sRGB profile is written once for all three images + assert_eq!(s.matches("/N 3 /Alternate").count(), 1); + let first: Vec<_> = images(&pdf).iter().map(|&id| reference(&object(&pdf, id).0, "/ICCBased")).collect(); + assert!(first.windows(2).all(|w| w[0] == w[1]), "{first:?}"); + // placements reach the content streams + let contents: Vec = s + .match_indices("/Contents ") + .map(|(i, _)| s[i + 10..].split(' ').next().unwrap().parse::().unwrap()) + .map(|id| String::from_utf8(inflate(&object(&pdf, id).1)).unwrap()) + .collect(); + assert_eq!(contents[0], "q 288 0 0 216 36 72.5 cm /Im0 Do Q\nq 612 0 0 792 0 0 cm /Im1 Do Q\n"); + assert_eq!(write(&pages, &PdfInfo::default()).unwrap(), pdf, "deterministic"); + } + + #[test] + fn output_intent_and_title() { + let cmyk_like = srgb(); + let info = PdfInfo { + title: Some("Garden – prints (2)".into()), + output_intent: Some(OutputIntent { icc: cmyk_like.clone(), channels: 3, condition: "Glossy (Photo) \\ test".into() }), + }; + let img = PdfImage::from_u16(1, 1, 3, &[0, 0, 0], Some(cmyk_like)); + let pdf = write(&[page(vec![(img, full(10.0, 10.0))])], &info).unwrap(); + let (catalog, _) = object(&pdf, 1); + assert!(catalog.contains("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX"), "{catalog}"); + assert!(catalog.contains("/Info (Glossy \\(Photo\\) \\\\ test)"), "{catalog}"); + // the intent and the image share one profile object + let dest = reference(&catalog, "/DestOutputProfile"); + assert_eq!(reference(&object(&pdf, images(&pdf)[0]).0, "/ICCBased"), dest); + let (info_dict, _) = object(&pdf, 3); + assert!(info_dict.contains("/Title = ids.iter().map(|&id| object(&pdf, id)).collect(); + assert!(dicts[0].0.contains("/ColorSpace /DeviceCMYK") && dicts[0].0.contains("/BitsPerComponent 8")); + assert!(dicts[1].0.contains("/ColorSpace /DeviceGray")); + assert!(dicts[2].0.contains("/DCTDecode") && dicts[2].0.contains("/DeviceRGB")); + assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are"); + } + + #[test] + fn compressing_early_writes_the_same_file() { + let samples: Vec = (0..4 * 3 * 3).map(|i| (i * 977) as u16).collect(); + let raw = PdfImage::from_u16(4, 3, 3, &samples, Some(srgb())); + let early = raw.clone().deflated().unwrap(); + assert!(matches!(early.data, ImageData::Flate { bits: 16, .. })); + let at = full(100.0, 75.0); + let a = write(&[page(vec![(raw, at)])], &PdfInfo::default()).unwrap(); + let b = write(&[page(vec![(early, at)])], &PdfInfo::default()).unwrap(); + assert_eq!(a, b); + let mut short = PdfImage::from_u16(4, 3, 3, &samples, None); + short.width = 5; + assert!(short.deflated().is_err(), "checked before compressing"); + } + + #[test] + fn hostile_documents_are_errors() { + let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None); + let at = full(10.0, 10.0); + let bad = |pages: Vec| write(&pages, &PdfInfo::default()).is_err(); + assert!(bad(vec![])); + assert!(bad(vec![PdfPage { width: f64::NAN, height: 10.0, images: vec![] }])); + assert!(bad(vec![PdfPage { width: -1.0, height: 10.0, images: vec![] }])); + assert!(bad(vec![PdfPage { width: 1e9, height: 10.0, images: vec![] }])); + assert!(bad(vec![page(vec![(ok(), Placement { x: f64::INFINITY, ..at })])])); + assert!(bad(vec![page(vec![(ok(), Placement { width: 0.0, ..at })])])); + let mut short = ok(); + short.data = ImageData::Raw { bits: 16, samples: vec![0; 5] }; + assert!(bad(vec![page(vec![(short, at)])])); + let mut empty = ok(); + empty.width = 0; + assert!(bad(vec![page(vec![(empty, at)])])); + let mut odd = ok(); + odd.channels = 2; + assert!(bad(vec![page(vec![(odd, at)])])); + let mut not_jpeg = ok(); + not_jpeg.data = ImageData::Jpeg(vec![1, 2, 3]); + assert!(bad(vec![page(vec![(not_jpeg, at)])])); + let too_many = (0..MAX_PAGES + 1).map(|_| page(vec![])).collect(); + assert!(bad(too_many)); + let info = PdfInfo { title: None, output_intent: Some(OutputIntent { icc: srgb(), channels: 2, condition: String::new() }) }; + assert!(write(&[page(vec![])], &info).is_err()); + } + + #[test] + fn numbers_and_strings() { + assert_eq!(num(612.0), "612"); + assert_eq!(num(72.5), "72.5"); + assert_eq!(num(-0.00001), "0"); + assert_eq!(num(f64::NAN), "0"); + assert_eq!(num(1.0 / 3.0), "0.3333"); + assert_eq!(text("a(b)c\\"), "(a\\(b\\)c\\\\)"); + assert_eq!(text("é"), ""); + } +} diff --git a/crates/engine/src/profile_refs.rs b/crates/engine/src/profile_refs.rs new file mode 100644 index 000000000..b9a7d2d37 --- /dev/null +++ b/crates/engine/src/profile_refs.rs @@ -0,0 +1,205 @@ +//! Profiles named by imported presets (issue #1001). +//! +//! A Lightroom preset doesn't carry its profile's look: it names it (`crs:Look`: name, group, +//! UUID, amount), and many presets get most of their look from that profile. On import the +//! reference is kept in the preset's settings as `profile.look`, and resolved against the +//! profiles the user has imported (by name, the group breaking ties), so the preset applies the +//! profile like the other app does. The six basic profiles of the other app map to our own basic +//! profiles; its other built-in profiles can't be used here and the import says so. +//! +//! References are resolved again whenever profiles are imported or deleted, so a pack works in +//! either order. Only the user's own files are read; no profile content ships with LightCraft. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::lut_profiles::LutProfile; +use crate::crs::Props; + +/// The profile named by a preset's `crs:Look`, as kept in its settings (`profile.look`): +/// `{name, group?, uuid?, amount}` (amount in percent, like [`lightcraft_develop::Profile`]). +pub fn look_ref(props: &Props) -> Option { + let field = |k: &str| props.get(&format!("crs:Look/crs:{k}")).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty()); + let name = field("Name")?; + // stored as a fraction (1 = 100 %), 0–2 + let amount = field("Amount").and_then(|a| a.parse::().ok()).filter(|a| a.is_finite()).map_or(100.0, |a| (a * 100.0).clamp(0.0, 200.0)); + let mut look = json!({"name": name, "amount": amount}); + if let Some(g) = field("Group") { + look["group"] = json!(g); + } + if let Some(u) = field("UUID") { + look["uuid"] = json!(u.to_ascii_lowercase()); + } + Some(look) +} + +/// What a preset's profile reference resolved to. +#[derive(Clone, Debug, PartialEq)] +pub enum Resolved { + /// A profile the user imported (`lut:…`). + Imported(String), + /// One of our built-in profiles standing in for the other app's basic profile of that name. + BuiltIn(String), + /// One of the other app's own creative or camera profiles: not available here. + OtherAppBuiltIn, + /// A third-party profile the user hasn't imported (yet). + NotImported, +} + +fn norm(s: &str) -> String { + s.split_whitespace().collect::>().join(" ").to_lowercase() +} + +/// The other app's basic profiles and ours that stand in for them. +const BASIC: &[(&str, &str)] = &[ + ("adobe color", "lc.color"), + ("adobe neutral", "lc.neutral"), + ("adobe vivid", "lc.vivid"), + ("adobe landscape", "lc.landscape"), + ("adobe portrait", "lc.portrait"), + ("adobe monochrome", "lc.mono"), +]; + +/// The other app's own profiles: everything named `Adobe …` or `Camera …`, and its numbered +/// creative profiles (`Artistic 01`, `B&W 05`, `Modern 10`, `Vintage 02`). +fn other_app_builtin(name: &str) -> bool { + let n = norm(name); + if n.starts_with("adobe ") || n.starts_with("camera ") { + return true; + } + ["artistic ", "b&w ", "modern ", "vintage "] + .iter() + .any(|p| n.strip_prefix(p).is_some_and(|rest| !rest.is_empty() && rest.len() <= 2 && rest.chars().all(|c| c.is_ascii_digit()))) +} + +/// Resolve `look` (a `profile.look` value) against the imported profiles. +pub fn resolve(look: &Value, luts: &[LutProfile]) -> Resolved { + let name = norm(look.get("name").and_then(Value::as_str).unwrap_or_default()); + let group = look.get("group").and_then(Value::as_str).map(norm); + if !name.is_empty() { + let same_name: Vec<&LutProfile> = luts.iter().filter(|p| norm(&p.name) == name).collect(); + let pick = same_name.iter().find(|p| group.as_deref() == Some(norm(&p.group).as_str())).or(same_name.first()); + if let Some(p) = pick { + return Resolved::Imported(p.id.clone()); + } + } + if let Some((_, ours)) = BASIC.iter().find(|(theirs, _)| *theirs == name) { + return Resolved::BuiltIn((*ours).to_string()); + } + if other_app_builtin(&name) { Resolved::OtherAppBuiltIn } else { Resolved::NotImported } +} + +/// Set (or clear) `profile.id` / `profile.amount` in a preset's partial settings from its +/// `profile.look`. Returns what the reference resolved to (`None`: the preset names no profile) +/// and whether `settings` changed. +pub fn apply(settings: &mut Value, luts: &[LutProfile]) -> Option<(Resolved, bool)> { + let profile = settings.get_mut("profile")?.as_object_mut()?; + let look = profile.get("look")?.clone(); + let r = resolve(&look, luts); + let before = (profile.get("id").cloned(), profile.get("amount").cloned()); + match &r { + Resolved::Imported(id) | Resolved::BuiltIn(id) => { + profile.insert("id".into(), json!(id)); + profile.insert("amount".into(), look.get("amount").cloned().unwrap_or(json!(100.0))); + } + // the photo keeps its own profile + Resolved::OtherAppBuiltIn | Resolved::NotImported => { + profile.remove("id"); + profile.remove("amount"); + } + } + let changed = before != (profile.get("id").cloned(), profile.get("amount").cloned()); + Some((r, changed)) +} + +/// How the import reports a reference it couldn't resolve (one of the preset's `unmapped` names). +pub fn report(look: &Value, r: &Resolved) -> Option { + let name = look.get("name").and_then(Value::as_str).unwrap_or_default(); + match r { + Resolved::Imported(_) | Resolved::BuiltIn(_) => None, + Resolved::OtherAppBuiltIn => Some(format!("Profile “{name}” (Lightroom's own)")), + Resolved::NotImported => Some(format!("Profile “{name}” (not imported yet)")), + } +} + +/// Resolve every user preset's profile reference again (after profiles were imported or deleted). +/// Only the presets that change are touched, so an unchanged list isn't saved again. +pub fn refresh(s: &mut Session) { + let luts = s.lut_profiles.clone(); + let stale: Vec = s + .presets + .iter() + .enumerate() + .filter(|(_, p)| !p.builtin) + .filter(|(_, p)| { + let mut settings = p.settings.clone(); + apply(&mut settings, &luts).is_some_and(|(_, changed)| changed) + }) + .map(|(i, _)| i) + .collect(); + if stale.is_empty() { + return; + } + let presets = &mut *s.presets; + for i in stale { + if let Some(p) = presets.get_mut(i) { + apply(&mut p.settings, &luts); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lut(id: &str, name: &str, group: &str) -> LutProfile { + LutProfile { id: id.into(), name: name.into(), group: group.into(), file: String::new() } + } + + #[test] + fn resolves_by_name_with_the_group_breaking_ties() { + let luts = [lut("lut:a", "Kodak Portra 400", "Films A"), lut("lut:b", "Kodak portra 400", "RNI Films")]; + let look = json!({"name": "Kodak Portra 400", "group": "RNI Films", "amount": 100.0}); + assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:b".into())); + let look = json!({"name": "kodak portra 400", "amount": 100.0}); + assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:a".into()), "first by name without a group"); + assert_eq!(resolve(&json!({"name": "Fuji 400H"}), &luts), Resolved::NotImported); + } + + #[test] + fn other_app_profiles_map_to_ours_or_are_reported() { + assert_eq!(resolve(&json!({"name": "Adobe Monochrome"}), &[]), Resolved::BuiltIn("lc.mono".into())); + for n in ["Adobe Standard", "Camera Faithful", "Artistic 01", "B&W 12", "Vintage 3"] { + assert_eq!(resolve(&json!({"name": n}), &[]), Resolved::OtherAppBuiltIn, "{n}"); + } + assert_eq!(resolve(&json!({"name": "Vintage Film"}), &[]), Resolved::NotImported); + // an imported profile wins over the mapping + assert_eq!(resolve(&json!({"name": "Adobe Vivid"}), &[lut("lut:v", "Adobe Vivid", "Mine")]), Resolved::Imported("lut:v".into())); + } + + #[test] + fn apply_sets_and_clears_the_profile() { + let mut s = json!({"profile": {"look": {"name": "mb1 profile", "amount": 80.0}}, "light": {"highlights": -6.0}}); + assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, false))); + let luts = [lut("lut:mb1", "mb1 profile", "Imported")]; + assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), true))); + assert_eq!((s["profile"]["id"].clone(), s["profile"]["amount"].clone()), (json!("lut:mb1"), json!(80.0))); + assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), false)), "already resolved"); + assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, true)), "profile deleted"); + assert!(s["profile"].get("id").is_none()); + assert_eq!(apply(&mut json!({"light": {}}), &luts), None); + } + + #[test] + fn look_ref_reads_name_group_uuid_and_amount() { + let x = r#" + + + Erich McVey B&W Profiles + + "#; + let props = lightcraft_meta::parse_xmp(x).unwrap().properties; + assert_eq!(look_ref(&props), Some(json!({"name": "mb1 profile", "amount": 75.0, "group": "Erich McVey B&W Profiles", "uuid": "0a1b2c"}))); + assert_eq!(look_ref(&Props::new()), None); + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 05ac3ee05..d565f31ab 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1583,3 +1583,35 @@ fn rotate_carries_the_crop_with_the_image() { assert_eq!(c.geometry.rect, left_half); assert_eq!(c.aspect, Some((2, 3))); } + +/// A call that names its photos with a malformed `id` or `ids` (a string, a mixed list) is refused and changes +/// nothing; it used to act on the selection, or on the valid part of the list (issue #1506). Leaving the targets +/// out still means the selection. +#[test] +fn malformed_photo_targets_are_refused_not_widened() { + use lightcraft_catalog::PhotoId; + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": [ids[0], ids[1]]})).unwrap(); + let rating = |s: &Session, i: u64| s.catalog.photo(PhotoId(i)).unwrap().rating; + let before: Vec = ids.iter().map(|i| rating(&s, *i)).collect(); + for bad in [ + json!({"rating": 5, "id": "5"}), + json!({"rating": 5, "ids": [ids[2], "x"]}), + json!({"rating": 5, "ids": ids[2].to_string()}), + json!({"rating": 5, "id": -1}), + ] { + let err = s.execute("photo.rate", &bad).unwrap_err(); + assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}"); + assert_eq!(ids.iter().map(|i| rating(&s, *i)).collect::>(), before, "{bad} changed a rating"); + } + let snapshot = s.catalog.to_snapshot(); + assert!(s.execute("photo.deletePermanently", &json!({"id": "5"})).is_err()); + assert_eq!(s.catalog.to_snapshot(), snapshot, "nothing deleted"); + // no targets, or a null one: the selection + s.execute("photo.rate", &json!({"rating": 4, "id": null})).unwrap(); + assert_eq!((rating(&s, ids[0]), rating(&s, ids[1]), rating(&s, ids[2])), (4, 4, before[2])); + // a preset's id is not a photo target + let preset = s.presets.first().unwrap().id.clone(); + s.execute("preset.apply", &json!({"id": preset})).unwrap(); +} diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 7b52f3d2f..2f1a05116 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -579,6 +579,30 @@ fn remove_location_holds_for_original_and_dng_exports() { 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] +fn metadata_none_in_any_case_exports_no_metadata() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap(); + let export = |s: &mut Session, extra: serde_json::Value| { + let mut p = json!({"format": "jpeg", "longEdge": 64}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + lightcraft_meta::extract(&export_photo(s, id, &ExportOptions::from_params(&p).unwrap(), 1).unwrap().bytes) + }; + let all = export(&mut s, json!({})); + assert!(all.gps.is_some() && all.city.is_some(), "the default keeps everything: {all:?}"); + for v in ["none", "NONE", "None"] { + let m = export(&mut s, json!({"metadata": v})); + assert!(m.gps.is_none() && m.city.is_none() && m.model.is_none(), "{v}: {m:?}"); + } + let m = export(&mut s, json!({"metadata": "ALL", "removeLocation": true})); + assert!(m.gps.is_none() && m.city.is_none(), "{m:?}"); +} + /// An original raw whose own metadata holds GPS is refused under "Remove location info". #[test] fn remove_location_refuses_a_raw_that_embeds_gps() { diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index a308f20fb..e244194ad 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -179,7 +179,7 @@ fn hostile_ids_unsupported_models_and_odd_folders_are_handled() { /// Settings > Faces downloads; nothing is committed). Without it those tests say so and pass. fn demo_with_yunet() -> Option { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; let dir = temp("yunet"); diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index 9f80ff8b8..b24a7bbb9 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -34,7 +34,7 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) { /// committed) and says whether it did: the parts of a test that need to find faces run only then. pub(crate) fn install_yunet(s: &mut Session) -> bool { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping the face-finding part"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set, so the face-finding part is not checked"); return false; }; let r = s.execute("faces.models.install", &json!({"path": file.to_string_lossy(), "acknowledged": true})); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 673c365af..3d6469deb 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1680,3 +1680,71 @@ fn raw_sidecar_rating_and_crop_survive_naming_and_encoding_variants() { let _ = std::fs::remove_dir_all(&src); } } + +/// Issue #1001: a Lightroom preset that names a profile applies it once the user has imported a +/// profile of that name (before or after the preset), maps the basic Adobe profiles to ours, and +/// reports the ones it can't use. +#[test] +fn presets_apply_the_imported_profile_they_name() { + let src = temp_dir("lookref"); + let preset = |file: &str, name: &str, look: &str| { + let x = format!( + r#" + + + Film Pack + + "# + ); + std::fs::write(src.join(format!("{file}.xmp")), x).unwrap(); + }; + preset("a1", "Portra Soft", "Portra 400"); + preset("a2", "Mono", "Adobe Monochrome"); + preset("a3", "Artsy", "Artistic 01"); + let mut s = Session::with_demo(); + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let by_name = |r: &serde_json::Value, n: &str| r["imported"].as_array().unwrap().iter().find(|i| i["name"] == n).cloned().unwrap(); + assert_eq!(by_name(&r, "Portra Soft")["unmapped"], json!(["Profile “Portra 400” (not imported yet)"]), "{r}"); + assert_eq!(by_name(&r, "Mono")["profile"], json!({"name": "Adobe Monochrome", "id": "lc.mono"})); + assert_eq!(by_name(&r, "Artsy")["unmapped"], json!(["Profile “Artistic 01” (Lightroom's own)"])); + let photo = s.catalog.photos().next().unwrap().id; + s.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); + let pid = |s: &Session, n: &str| s.presets.iter().find(|p| p.name == n).unwrap().id.clone(); + let apply = |s: &mut Session, n: &str| { + let id = pid(s, n); + s.execute("preset.apply", &json!({"id": id})).unwrap(); + s.develop_of(photo).unwrap() + }; + // not imported yet: only grain comes across, the photo keeps its profile + let d = apply(&mut s, "Portra Soft"); + assert_eq!((d.profile.id.as_str(), d.grain.amount), ("lc.color", 20.0)); + // the basic Adobe profiles are ours + assert_eq!(apply(&mut s, "Mono").profile.id, "lc.mono"); + s.execute("develop.profile", &json!({"id": "lc.color", "amount": 100})).unwrap(); + assert_eq!(apply(&mut s, "Artsy").profile.id, "lc.color", "Lightroom's own creative profiles aren't ours"); + // importing the profile afterwards makes the preset use it, at the preset's amount + let mut cube = String::from("TITLE \"Portra 400\"\nLUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube.push_str(&format!("{r} {g} {b}\n")); + } + } + } + std::fs::create_dir_all(src.join("profiles/Film Pack")).unwrap(); + std::fs::write(src.join("profiles/Film Pack/portra.cube"), &cube).unwrap(); + let r = s.execute("profile.import", &json!({"paths": [src.join("profiles").to_string_lossy()]})).unwrap(); + let lut = r["imported"][0]["id"].as_str().unwrap().to_string(); + let d = apply(&mut s, "Portra Soft"); + assert_eq!((d.profile.id.as_str(), d.profile.amount, d.grain.amount), (lut.as_str(), 60.0, 20.0)); + // a preset imported after the profile resolves straight away + preset("a4", "Portra Hard", "portra 400"); + let r = s.execute("preset.import", &json!({"paths": [src.join("a4.xmp").to_string_lossy()]})).unwrap(); + assert_eq!((r["imported"][0]["profile"]["id"].as_str(), r["imported"][0]["unmapped"].clone()), (Some(lut.as_str()), json!([])), "{r}"); + // deleting the profile leaves the photo's own profile alone again + s.execute("profile.deleteImported", &json!({"id": lut})).unwrap(); + s.execute("develop.profile", &json!({"id": "lc.vivid", "amount": 100})).unwrap(); + assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index e642c57b6..5460dc020 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -440,6 +440,21 @@ fn scale_100k_library_queries() { m("undo", &mut || { s.execute("edit.undo", &serde_json::json!({})).unwrap(); }); + // a 20k-photo album shown in its own order (capture-date sort, no filter) + let big = s.execute("album.create", &serde_json::json!({"name": "Big"})).unwrap()["id"].as_u64().unwrap(); + let ids: Vec = (0..n).rev().step_by(5).map(|i| i + 1).collect(); + m("add 20k to an album", &mut || { + s.execute("album.addPhotos", &serde_json::json!({"id": big, "ids": ids})).unwrap(); + }); + m("album of 20k (album order)", &mut || { + s.execute("library.source", &serde_json::json!({"kind": "album", "id": big})).unwrap(); + let _ = s.visible_cloned(); + }); + s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); + let half: Vec = ids.iter().copied().step_by(2).collect(); + m("remove 10k from the album", &mut || { + s.execute("album.removePhotos", &serde_json::json!({"id": big, "ids": half})).unwrap(); + }); worst.sort_by(|a, b| b.1.total_cmp(&a.1)); eprintln!("slowest: {:?}", &worst[..3]); } diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs new file mode 100644 index 000000000..5b890a7f0 --- /dev/null +++ b/crates/engine/src/tests_print.rs @@ -0,0 +1,343 @@ +//! The `print.*` commands: state, cells, templates and the PDF. + +use lightcraft_catalog::MemStore; +use serde_json::{Value, json}; + +use crate::Session; +use crate::library::LibraryStores; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn fails(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string() +} + +fn package(s: &mut Session) { + run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}})); +} + +#[test] +fn get_set_and_reset() { + let mut s = Session::with_demo(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["layout"]["style"], "singleImage"); + assert_eq!(g["pageWidth"], 8.5); + assert_eq!(g["printable"]["x"], 0.25); + assert_eq!(g["settings"]["job"]["ppi"], 240.0); + assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4")); + assert_eq!(g["gridCells"].as_array().unwrap().len(), 1); + + let g = run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 2, "rows": 3}, "page": {"paper": "a4", "orientation": "landscape", "margins": {"left": 1.0}}}, "image": {"zoomToFill": true}, "job": {"ppi": 300.0}}), + ); + assert_eq!(g["settings"]["layout"]["page"]["margins"]["left"], 1.0); + assert_eq!(g["settings"]["layout"]["page"]["margins"]["top"], 0.25, "a patch merges"); + assert_eq!(g["gridCells"].as_array().unwrap().len(), 6); + assert!((g["pageWidth"].as_f64().unwrap() - 11.693).abs() < 1e-3); + assert_eq!(g["settings"]["image"]["zoomToFill"], true); + // the page count follows the photos: 6 cells per page + let g = run(&mut s, "print.get", json!({"ids": vec![1; 7]})); + assert_eq!(g["pageCount"], 2); + + let g = run(&mut s, "print.reset", json!({})); + assert_eq!(g["settings"]["job"]["ppi"], 240.0); +} + +#[test] +fn bad_settings_are_refused_and_change_nothing() { + let mut s = Session::with_demo(); + let before = run(&mut s, "print.get", json!({})); + for p in [ + json!({"typo": 1}), + json!({"layout": {"typo": 1}}), + json!({"layout": {"style": "poster"}}), + json!({"layout": {"cells": []}}), + json!({"layout": {"pages": 3}}), + json!({"layout": {"page": {"paper": "poster"}}}), + json!({"layout": {"page": {"paper": "custom", "customWidth": -1}}}), + json!({"layout": {"page": {"margins": {"top": 1e9}}}}), + json!({"layout": {"page": {"margins": {"top": 6.0, "bottom": 5.0}}}}), + json!({"layout": {"grid": {"columns": 0}}}), + json!({"layout": {"grid": {"columns": 9999}}}), + json!({"layout": {"spacing": -1}}), + json!({"image": {"borderWidth": 99}}), + json!({"image": {"strokeWidthPt": -2}}), + json!({"image": {"borderColor": [1, 2]}}), + json!({"image": {"zoomToFill": "yes"}}), + json!({"guides": {"gridSize": 0}}), + json!({"guides": {"units": "furlong"}}), + json!({"job": {"ppi": 1e30}}), + json!({"job": {"ppi": 10}}), + json!({"job": null}), + ] { + fails(&mut s, "print.set", p); + } + assert_eq!(run(&mut s, "print.get", json!({})), before); + assert!(s.execute("print.set", &json!([1])).is_err()); +} + +#[test] +fn cells_are_commands() { + let mut s = Session::with_demo(); + // grid styles have no cells + fails(&mut s, "print.cell.add", json!({"width": 2, "height": 2})); + package(&mut s); + let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0, "x": 5.0, "y": 5.0}))["id"].as_u64().unwrap(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["layout"]["cells"].as_array().unwrap().len(), 2); + assert_eq!(g["overlaps"], false); + // move, clamped inside the margins + let r = run(&mut s, "print.cell.move", json!({"id": a, "x": 99.0, "y": 0.0})); + assert_eq!(r["rect"]["x"], 8.25 - 3.0); + assert_eq!(r["rect"]["y"], 0.25); + // resize keeps what is not given + let r = run(&mut s, "print.cell.resize", json!({"id": b, "width": 4.0})); + assert_eq!((r["rect"]["w"].as_f64().unwrap(), r["rect"]["h"].as_f64().unwrap()), (4.0, 2.0)); + // snapping to the other cell's edge + let ra = run(&mut s, "print.get", json!({}))["settings"]["layout"]["cells"][0]["rect"].clone(); + let left = ra["x"].as_f64().unwrap(); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.0)).abs() < 1e-9, "{r}"); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "no snap unless asked: {r}"); + run(&mut s, "print.set", json!({"guides": {"snap": "off"}})); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "snap off"); + // stacking + let r = run(&mut s, "print.cell.stack", json!({"id": a, "order": "toFront"})); + assert_eq!(r["order"], json!([b, a])); + fails(&mut s, "print.cell.stack", json!({"id": a, "order": "sideways"})); + // photos and pages + let photo = s.catalog.photos().next().unwrap().id.0; + run(&mut s, "print.cell.photo", json!({"id": a, "photo": photo})); + fails(&mut s, "print.cell.photo", json!({"id": a, "photo": 999_999})); + run(&mut s, "print.layout.newPage", json!({})); + run(&mut s, "print.cell.page", json!({"id": b, "page": 1})); + fails(&mut s, "print.cell.page", json!({"id": b, "page": 7})); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["pageCount"], 2); + // delete, clear + run(&mut s, "print.cell.delete", json!({"id": b})); + fails(&mut s, "print.cell.delete", json!({"id": b})); + fails(&mut s, "print.cell.move", json!({"id": 99, "x": 0, "y": 0})); + fails(&mut s, "print.cell.move", json!({"x": 0, "y": 0})); + run(&mut s, "print.layout.clear", json!({})); + let g = run(&mut s, "print.get", json!({})); + assert_eq!((g["settings"]["layout"]["cells"].as_array().unwrap().len(), g["pageCount"].as_u64()), (0, Some(1))); +} + +#[test] +fn hostile_cell_numbers_are_refused() { + let mut s = Session::with_demo(); + package(&mut s); + for (w, h) in [(0.0, 2.0), (-1.0, 2.0), (1e12, 1e12), (2.0, 1e-9), (50.0, 50.0)] { + fails(&mut s, "print.cell.add", json!({"width": w, "height": h})); + } + fails(&mut s, "print.cell.add", json!({"width": "wide", "height": 2})); + fails(&mut s, "print.cell.add", json!({"width": 2})); + let id = run(&mut s, "print.cell.add", json!({"width": 2, "height": 2}))["id"].as_u64().unwrap(); + fails(&mut s, "print.cell.move", json!({"id": id, "x": "left", "y": 0})); + fails(&mut s, "print.cell.resize", json!({"id": id, "width": -4})); + fails(&mut s, "print.cell.resize", json!({"id": id, "width": "x"})); + fails(&mut s, "print.cell.move", json!({"id": id, "x": 1, "y": 1, "snap": "yes"})); + fails(&mut s, "print.cell.move", json!({"id": u64::MAX, "x": 1, "y": 1})); + let r = run(&mut s, "print.cell.move", json!({"id": id, "x": -1e300, "y": 1e300})); + assert_eq!(r["rect"]["x"], 0.25); +} + +#[test] +fn auto_layout_fills_pages_without_overlap() { + let mut s = Session::with_demo(); + run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}})); + for (w, h) in [(4.0, 6.0), (4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (8.0, 10.0)] { + run(&mut s, "print.cell.add", json!({"width": w, "height": h})); + } + let r = run(&mut s, "print.layout.autoLayout", json!({})); + assert!(r["pages"].as_u64().unwrap() >= 2); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["overlaps"], false); + let again = run(&mut s, "print.layout.autoLayout", json!({})); + assert_eq!(again["cells"], r["cells"], "deterministic"); + // an oversize cell that arrived by a paper change is pulled in, not left outside the page + run(&mut s, "print.set", json!({"layout": {"page": {"paper": "4x6"}}})); + let g = run(&mut s, "print.get", json!({})); + for c in g["settings"]["layout"]["cells"].as_array().unwrap() { + assert!(c["rect"]["x"].as_f64().unwrap() + c["rect"]["w"].as_f64().unwrap() <= 4.0 - 0.25 + 1e-9); + } +} + +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + +fn temp_pdf(name: &str) -> String { + std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string() +} + +#[test] +fn export_pdf_prints_the_current_settings() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 2}, "page": {"paper": "a5"}}, "job": {"ppi": 100.0}}), + ); + let path = temp_pdf("export"); + let r = run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["photos"].as_u64()), (Some(2), Some(3))); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap()); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-") && text.contains("/Count 2")); + // A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827) + let (w, h) = media_box(&bytes); + assert!((w - 419.53).abs() < 1.0 && (h - 595.28).abs() < 1.0, "A5, got {w} x {h} pt"); + // the row in the activity stack is gone when the print is done + assert!(s.activity.list().is_empty()); + fails(&mut s, "print.exportPdf", json!({})); + fails(&mut s, "print.exportPdf", json!({"path": path, "typo": 1})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ["x"]})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": [999_999]})); + // a package with no cells has nothing to print + run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); +} + +#[test] +fn export_pdf_with_a_printer_profile() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(1).map(|p| p.id.0).collect(); + let dir = std::env::temp_dir().join(format!("lightcraft-print-icc-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Fine Art Matte.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let color = json!({"profile": {"kind": "icc", "path": icc}, "intent": "relative", "sharpen": "high", "media": "matte", "brightness": 10}); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0, "color": color}}), + ); + assert_eq!(run(&mut s, "print.get", json!({}))["settings"]["job"]["color"]["media"], "matte"); + let path = temp_pdf("icc"); + run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + let text = String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned(); + assert!(text.contains("/OutputIntents") && text.contains("/ICCBased") && text.contains("/BitsPerComponent 16")); + // out-of-range adjustments are refused when set; a missing profile fails before rendering + fails(&mut s, "print.set", json!({"job": {"color": {"contrast": 150}}})); + run(&mut s, "print.set", json!({"job": {"color": {"profile": {"kind": "icc", "path": dir.join("gone.icc")}}}})); + let err = fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + assert!(err.contains("gone.icc"), "{err}"); + assert!(s.activity.list().is_empty(), "nothing started"); +} + +#[test] +fn custom_package_prints_each_cells_photo() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + run(&mut s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}})); + let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + run(&mut s, "print.cell.photo", json!({"id": a, "photo": ids[0]})); + run(&mut s, "print.cell.photo", json!({"id": b, "photo": ids[1]})); + let r = run(&mut s, "print.exportPdf", json!({"path": temp_pdf("custom"), "ids": [ids[0]]})); + assert_eq!(r["pages"], 1); +} + +#[test] +fn templates_save_apply_delete_and_persist() { + let files = MemStore::new(); + let open = |files: &MemStore| { + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s + }; + let mut s = open(&files); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 4, "rows": 5}}, "page": {"background": [10, 20, 30]}, "guides": {"gridSize": 1.0}}), + ); + let r = run(&mut s, "print.template.save", json!({"name": "Proofs"})); + assert_eq!(r["templates"][0]["name"], "Proofs"); + assert!(r["templates"][0]["settings"].get("guides").is_none()); + run(&mut s, "print.template.save", json!({"name": "proofs"})); + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 1, "the same name in another case replaces"); + + run(&mut s, "print.reset", json!({})); + run(&mut s, "print.set", json!({"guides": {"gridSize": 0.5}})); + let g = run(&mut s, "print.template.apply", json!({"name": "PROOFS"})); + assert_eq!(g["settings"]["layout"]["grid"]["columns"], 4); + assert_eq!(g["settings"]["page"]["background"], json!([10, 20, 30])); + assert_eq!(g["settings"]["guides"]["gridSize"], 0.5, "guides stay as they were"); + + // it survives reopening the library + let mut again = open(&files); + assert_eq!(run(&mut again, "print.template.list", json!({}))["templates"][0]["name"], "proofs"); + run(&mut again, "print.template.apply", json!({"name": "Proofs"})); + + fails(&mut s, "print.template.save", json!({"name": " "})); + fails(&mut s, "print.template.save", json!({})); + fails(&mut s, "print.template.apply", json!({"name": "Nope"})); + fails(&mut s, "print.template.delete", json!({"name": "Nope"})); + run(&mut s, "print.template.delete", json!({"name": "proofs"})); + assert!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); + assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); +} + +#[test] +fn a_hostile_template_in_the_preferences_does_not_break_the_library() { + let files = MemStore::new(); + let hostile = json!({"printTemplates": [ + {"name": "Bad", "settings": {"layout": {"pages": 0}, "extra": true}}, + {"name": "Huge", "settings": {"job": {"ppi": 1e300}}}, + {"name": "Fine", "settings": {"layout": {"style": "contactSheet"}}} + ], "cacheMb": 123}); + files.set("prefs.json", serde_json::to_vec(&hostile).unwrap()); + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + assert!(s.take_library_warnings().is_empty(), "the preferences are not damaged"); + assert_eq!(s.cache_mb, 123); + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 3); + fails(&mut s, "print.template.apply", json!({"name": "Bad"})); + fails(&mut s, "print.template.apply", json!({"name": "Huge"})); + run(&mut s, "print.template.apply", json!({"name": "Fine"})); +} + +#[test] +fn every_print_command_is_registered_with_a_description() { + for id in [ + "print.get", + "print.set", + "print.reset", + "print.cell.add", + "print.cell.move", + "print.cell.resize", + "print.cell.delete", + "print.cell.stack", + "print.cell.photo", + "print.cell.page", + "print.layout.autoLayout", + "print.layout.newPage", + "print.layout.clear", + "print.exportPdf", + "print.template.list", + "print.template.save", + "print.template.apply", + "print.template.delete", + ] { + let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing")); + assert!(c.params.len() > 3, "{id}"); + } +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 6921a6f8c..231a7bee3 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -584,8 +584,10 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec, xm }, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap() } @@ -666,7 +668,7 @@ fn convert_raw_to_dng() { let r = s.execute("photo.convertToDng", &json!({})).unwrap(); assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1)); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let nef = dir.join("shot.nef"); @@ -928,7 +930,7 @@ fn dng_export_compression_choices() { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d5100-uncompressed.nef"); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let mut s = Session::new().with_fs(); @@ -973,7 +975,7 @@ fn dngs_of_raws_without_colour_matrices_carry_the_colour_they_open_with() { std::fs::write(&path, &bytes).unwrap(); let mut opened = lightcraft_raw::decode(&bytes).unwrap(); assert!(!has_matrix(&opened.color)); - let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None); + let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None, None); assert_eq!((want.matrix_is_fallback, look.is_none()), (!measured, true), "{model}"); let mut s = Session::new().with_fs(); diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs index 5c950069b..eedebc2c1 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/preview_read.rs @@ -13,7 +13,7 @@ use std::time::Instant; #[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"] fn a_preview_reads_only_the_header_and_the_preview() { let Ok(path) = std::env::var("LC_PREVIEW_RAW") else { - eprintln!("skipped: set LC_PREVIEW_RAW="); + lightcraft_testkit::skip!(Input, "set LC_PREVIEW_RAW="); return; }; let file_len = std::fs::metadata(&path).expect("the file exists").len(); diff --git a/crates/faces/Cargo.toml b/crates/faces/Cargo.toml index 112967cee..78ea0b798 100644 --- a/crates/faces/Cargo.toml +++ b/crates/faces/Cargo.toml @@ -22,4 +22,6 @@ sha2 = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs index b77f318bf..56f5d6474 100644 --- a/crates/faces/src/yunet.rs +++ b/crates/faces/src/yunet.rs @@ -263,7 +263,7 @@ mod tests { fn detector() -> Option { let Some(bytes) = model() else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; Some(Detector::new(&bytes).unwrap()) diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index dc348ee88..5547c548d 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -29,5 +29,7 @@ default = [] denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } lightcraft-scenes = { workspace = true } lightcraft-raw = { workspace = true } diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 4f078ff93..5872bfaa9 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -907,7 +907,7 @@ mod tests { match runner(net, tile) { Ok(r) => Some(r), Err(e) => { - eprintln!("skipped: no GPU runner here ({e})"); + lightcraft_testkit::skip!(Gpu, "no GPU runner here ({e})"); None } } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index fcf222191..c877e3d9d 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -19,7 +19,7 @@ const MAX_LSB: u8 = 3; fn gpu() -> bool { let ok = lightcraft_gpu::available(); if !ok { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); } ok } diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/fallback.rs index fe2ecad5e..05347b446 100644 --- a/crates/gpu/tests/fallback.rs +++ b/crates/gpu/tests/fallback.rs @@ -38,7 +38,7 @@ fn masked(s: &mut DevelopSettings, k: usize) { #[test] fn failed_gpu_renders_fall_back_with_a_reason() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } assert_eq!(lightcraft_gpu::unavailable_reason(), None); diff --git a/crates/gpu/tests/gpu_probe.rs b/crates/gpu/tests/gpu_probe.rs new file mode 100644 index 000000000..e006fbc80 --- /dev/null +++ b/crates/gpu/tests/gpu_probe.rs @@ -0,0 +1,87 @@ +//! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of +//! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs; +//! xtask runs it after the workspace tests, so it costs no extra build: +//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`. +//! +//! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`, +//! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build +//! contains. +//! - `LIGHTCRAFT_GPU_PROBE=` (`vulkan`, `metal`, `dx12`, `gl`): every adapter that +//! backend lists, software ones (lavapipe, llvmpipe, WARP) included. One backend per process, so +//! a driver that crashes while it is enumerated (issue #136) costs only its own line. +//! +//! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy. +#![cfg(not(target_arch = "wasm32"))] + +use lightcraft_gpu::backend::{self, Backends}; + +const NAMES: [(&str, Backends); 4] = [("vulkan", Backends::VULKAN), ("metal", Backends::METAL), ("dx12", Backends::DX12), ("gl", Backends::GL)]; + +#[test] +#[ignore = "the capability probe of `cargo xtask ci`"] +fn gpu_probe() { + let line = match std::env::var("LIGHTCRAFT_GPU_PROBE").ok().filter(|b| !b.is_empty()) { + None => pipeline(), + Some(name) => adapters(&name), + }; + // its own line, prefixed: libtest may have printed `test gpu_probe ... ` on this one + println!("\ngpu_probe: {line}"); +} + +fn names(set: Backends) -> String { + let list: Vec = NAMES.iter().filter(|(_, b)| set.contains(*b)).map(|(n, _)| json_str(n)).collect(); + format!("[{}]", list.join(",")) +} + +fn pipeline() -> String { + let available = lightcraft_gpu::available(); + let opt = |s: Option| s.map_or_else(|| "null".to_owned(), |s| json_str(&s)); + let compiled = wgpu::Instance::enabled_backend_features(); + format!( + "{{\"pipeline\":{{\"available\":{available},\"adapter\":{},\"reason\":{},\"backends\":{},\"compiled\":{}}}}}", + opt(lightcraft_gpu::adapter_name()), + opt(lightcraft_gpu::unavailable_reason()), + names(backend::compute_backends().unwrap_or(Backends::empty()) & compiled), + names(compiled), + ) +} + +fn adapters(name: &str) -> String { + let (_, set) = NAMES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("unknown backend `{name}` (vulkan, metal, dx12, gl)")); + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = *set; + desc.backend_options = backend::backend_options(); + desc.flags = backend::instance_flags_from_env(desc.flags); + let instance = wgpu::Instance::new(desc); + let adapters: Vec = pollster::block_on(instance.enumerate_adapters(*set)) + .iter() + .map(|a| { + let i = a.get_info(); + format!( + "{{\"name\":{},\"type\":{},\"vendor\":\"0x{:04x}\",\"device\":\"0x{:04x}\",\"driver\":{},\"driver_info\":{}}}", + json_str(&i.name), + json_str(&format!("{:?}", i.device_type)), + i.vendor, + i.device, + json_str(&i.driver), + json_str(&i.driver_info), + ) + }) + .collect(); + format!("{{\"backend\":{},\"adapters\":[{}]}}", json_str(name), adapters.join(",")) +} + +/// A JSON string literal. +fn json_str(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/crates/gpu/tests/memory.rs b/crates/gpu/tests/memory.rs index 2ef958cb1..25784f001 100644 --- a/crates/gpu/tests/memory.rs +++ b/crates/gpu/tests/memory.rs @@ -9,7 +9,7 @@ use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache}; #[test] fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; } let limit = 64u64 << 20; diff --git a/crates/gpu/tests/shutdown.rs b/crates/gpu/tests/shutdown.rs index b827a0711..a8d5cbd23 100644 --- a/crates/gpu/tests/shutdown.rs +++ b/crates/gpu/tests/shutdown.rs @@ -21,7 +21,8 @@ fn nothing_renders_on_the_gpu_once_the_process_is_ending() { let req = RenderRequest::fit(w, h); let has_gpu = lightcraft_gpu::available(); if !has_gpu { - eprintln!("no GPU adapter ({:?}): only the CPU side is checked", lightcraft_gpu::unavailable_reason()); + let why = lightcraft_gpu::unavailable_reason().unwrap_or_default(); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({why}), so only the CPU side is checked"); } assert!(!lightcraft_gpu::shutting_down()); assert!(lightcraft_gpu::wait_idle(Duration::ZERO), "nothing is in flight yet"); diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 9ce00a505..1316bfd13 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -6,7 +6,7 @@ use lightcraft_geom::Orientation; use lightcraft_meta::Metadata; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use lightcraft_raw::{ColorData, Opcode}; +use lightcraft_raw::{ColorData, Opcode, RawImage}; use crate::{MergeError, Result}; @@ -19,6 +19,21 @@ pub enum FrameColor { Linear { to_xyz_d50: Mat3 }, } +impl FrameColor { + /// Make `multipliers` (green = 1) the as-shot white balance of camera colour. A DNG says it as `AsShotNeutral`, the + /// reciprocal of the multipliers. The source's `AsShotWhiteXY` goes: a reader takes it before the neutral and the + /// DNG spec has the two exclude each other, so a stale one would hide the white balance set here. Linear colour has + /// no white balance to set, and multipliers that aren't finite and above zero change nothing. + pub fn set_as_shot_multipliers(&mut self, multipliers: [f32; 3]) { + if let FrameColor::Camera(c) = self + && multipliers.iter().all(|v| v.is_finite() && *v > 0.0) + { + c.as_shot_neutral = Some(multipliers.map(|v| 1.0 / f64::from(v))); + c.as_shot_white_xy = None; + } + } +} + /// One input photo. #[derive(Clone, Debug)] pub struct Frame { @@ -40,6 +55,9 @@ pub struct Frame { /// in order), re-expressed for `image` (the default crop, not yet turned), for the merged DNG to carry. Empty for /// other sources and once [`Frame::orient`] has turned the pixels. pub lens: Vec, + /// Raw files: the as-shot white-balance multipliers from the vendor's maker notes (green = 1), for formats such + /// as CR2 and NEF that have no `AsShotNeutral` of their own. `None` when the file doesn't say. + pub wb_multipliers: Option<[f32; 3]>, } impl Frame { @@ -58,6 +76,7 @@ impl Frame { metadata: Metadata::default(), baseline_exposure: 0.0, lens: Vec::new(), + wb_multipliers: None, } } @@ -87,6 +106,29 @@ pub fn exif_exposure(m: &Metadata) -> Option { Some((t * iso / (n * n)).log2()) } +/// The as-shot white balance `frames` agree on, as multipliers with green = 1: the median R/G and the median B/G of +/// the frames that have usable multipliers (all three finite and above zero), so one frame the camera balanced +/// differently doesn't move it. With an even number of voters the median is the mean of the middle two. Frames without +/// usable multipliers don't vote; `None` when none does. +pub fn consensus_multipliers<'a>(frames: impl IntoIterator) -> Option<[f32; 3]> { + let (mut r_over_g, mut b_over_g) = (Vec::new(), Vec::new()); + for [r, g, b] in frames.into_iter().filter_map(|f| f.wb_multipliers) { + if [r, g, b].iter().all(|v| v.is_finite() && *v > 0.0) { + r_over_g.push(f64::from(r) / f64::from(g)); + b_over_g.push(f64::from(b) / f64::from(g)); + } + } + Some([median(r_over_g)? as f32, 1.0, median(b_over_g)? as f32]) +} + +/// The median of `v` (the mean of the middle two when the count is even); `None` when empty. +fn median(mut v: Vec) -> Option { + v.sort_by(f64::total_cmp); + let upper = *v.get(v.len() / 2)?; + let lower = *v.get(v.len().checked_sub(1)? / 2)?; + Some((lower + upper) / 2.0) +} + /// The saturation level of a frame: 1.0, or the (lower) level at which values pile up when the /// file's white level is optimistic. Returned slightly below the level. pub fn detect_clip(img: &Rgb32f) -> f32 { @@ -125,56 +167,8 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo let metadata = lightcraft_meta::extract(bytes); let exposure = exif_exposure(&metadata); if lightcraft_raw::probe(bytes).is_some() { - let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; - // Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and - // the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps. - // The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional. - let (aw, ah) = (raw.active_area.width, raw.active_area.height); - let crop = raw.develop_crop(aw, ah); - let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3) - .filter(|op| op.is_lens_correction()) - .filter_map(|op| op.cropped(aw, ah, crop)) - .map(|op| match op { - Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => { - Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false } - } - op => op, - }) - .collect(); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - if let Some(color) = colour(&mut raw, bytes) { - raw.color = color; - } - let small = max_edge.is_some_and(|m| m <= 1200); - let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; - for p in image.data.iter_mut() { - *p = p.map(|v| v.max(0.0)); - } - let clip = detect_clip(&image); - if let Some(m) = max_edge - && image.width.max(image.height) > m - { - image = fit(&image, m, m, Filter::Box); - } - let mut f = Frame { - image, - clip, - exposure, - raw: true, - color: FrameColor::Camera(Box::new(raw.color.clone())), - orientation: raw.orientation, - metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, - baseline_exposure: raw.color.baseline_exposure, - lens, - }; - if f.exposure.is_none() { - f.exposure = exif_exposure(&f.metadata); - } - if orient { - f.orient(); - } - return Ok(f); + let raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; + return raw_frame(raw, bytes, metadata, exposure, max_edge, orient, colour); } let opts = match max_edge { Some(m) => lightcraft_codecs::DecodeOptions::fit(m as u32, m as u32), @@ -198,6 +192,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo metadata, baseline_exposure: 0.0, lens: Vec::new(), + wb_multipliers: None, }; if orient { f.orient(); @@ -205,6 +200,67 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option, orient: bool, colo Ok(f) } +/// A decoded raw (from `bytes`, which `colour` may read) as a frame: developed to camera RGB, with its colour tags, +/// its as-shot white-balance multipliers and its orientation. +fn raw_frame( + mut raw: RawImage, + bytes: &[u8], + metadata: Metadata, + exposure: Option, + max_edge: Option, + orient: bool, + colour: &RawColour, +) -> Result { + // Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and + // the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps. + // The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional. + let (aw, ah) = (raw.active_area.width, raw.active_area.height); + let crop = raw.develop_crop(aw, ah); + let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3) + .filter(|op| op.is_lens_correction()) + .filter_map(|op| op.cropped(aw, ah, crop)) + .map(|op| match op { + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false }, + op => op, + }) + .collect(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(color) = colour(&mut raw, bytes) { + raw.color = color; + } + let small = max_edge.is_some_and(|m| m <= 1200); + let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; + for p in image.data.iter_mut() { + *p = p.map(|v| v.max(0.0)); + } + let clip = detect_clip(&image); + if let Some(m) = max_edge + && image.width.max(image.height) > m + { + image = fit(&image, m, m, Filter::Box); + } + let mut f = Frame { + image, + clip, + exposure, + raw: true, + color: FrameColor::Camera(Box::new(raw.color.clone())), + orientation: raw.orientation, + metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, + baseline_exposure: raw.color.baseline_exposure, + lens, + wb_multipliers: raw.wb_multipliers, + }; + if f.exposure.is_none() { + f.exposure = exif_exposure(&f.metadata); + } + if orient { + f.orient(); + } + Ok(f) +} + #[cfg(test)] mod tests { use super::*; @@ -230,4 +286,126 @@ mod tests { } assert!((detect_clip(&img) - 0.9 * 0.995).abs() < 1e-6); } + + /// A 16×12 CR2-style mosaic (no colour tags, as the vendor decoders leave them) with as-shot multipliers. + fn mosaic(wb_multipliers: Option<[f32; 3]>) -> RawImage { + use lightcraft_raw::{BlackLevel, Cfa, OpcodeLists, RawData, RawFormat, Rect}; + let (w, h) = (16usize, 12usize); + RawImage { + format: RawFormat::Cr2, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers, + linearized: false, + opcodes: OpcodeLists::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, + metadata: Metadata::default(), + } + } + + fn frame_of(raw: RawImage, colour: &RawColour) -> Frame { + raw_frame(raw, &[], Metadata::default(), None, None, true, colour).unwrap() + } + + /// Issue #1144: the as-shot multipliers of a CR2, NEF… (its only white balance) were dropped on loading, so + /// nothing downstream could tell a merged DNG what the camera had balanced to. + #[test] + fn a_raw_frame_carries_the_as_shot_multipliers() { + let wb = [2.05, 1.0, 1.62]; + let f = frame_of(mosaic(Some(wb)), &|_, _| None); + assert!(f.raw); + assert_eq!(f.wb_multipliers, Some(wb)); + // colour tags the caller works out leave them alone + let own = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() }; + let f = frame_of(mosaic(Some(wb)), &|_, _| Some(own.clone())); + assert_eq!(f.wb_multipliers, Some(wb)); + assert_eq!(f.color, FrameColor::Camera(Box::new(own))); + // a file that gives none gives none + assert_eq!(frame_of(mosaic(None), &|_, _| None).wb_multipliers, None); + assert_eq!(Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).wb_multipliers, None); + } + + fn voter(wb_multipliers: Option<[f32; 3]>) -> Frame { + Frame { wb_multipliers, ..Frame::from_linear_rec2020(Rgb32f::new(2, 2), None) } + } + + fn consensus(wb: &[Option<[f32; 3]>]) -> Option<[f32; 3]> { + let frames: Vec = wb.iter().map(|m| voter(*m)).collect(); + consensus_multipliers(&frames) + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) + } + + #[test] + fn the_consensus_is_the_median_of_the_voters() { + // one voter is its own median + assert_eq!(consensus(&[Some([2.0, 1.0, 1.6])]), Some([2.0, 1.0, 1.6])); + // an odd count takes the middle one of each ratio, whichever frame it came from, whatever the order + let odd = [Some([2.4, 1.0, 1.4]), Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&odd), Some([2.0, 1.0, 1.6])); + // an outlier doesn't pull it + let outlier = [Some([2.0, 1.0, 1.6]), Some([2.1, 1.0, 1.5]), Some([1.9, 1.0, 1.7]), Some([5.0, 1.0, 0.7]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&outlier), Some([2.0, 1.0, 1.6])); + // an even count takes the mean of the middle two + let even = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + assert!(close(consensus(&even).unwrap(), [2.1, 1.0, 1.55]), "{:?}", consensus(&even)); + let two = [Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])]; + assert!(close(consensus(&two).unwrap(), [2.5, 1.0, 1.5]), "{:?}", consensus(&two)); + // green is the unit even when a file's multipliers aren't normalised to it + assert!(close(consensus(&[Some([4.0, 2.0, 3.0])]).unwrap(), [2.0, 1.0, 1.5])); + } + + #[test] + fn frames_without_usable_multipliers_do_not_vote() { + assert_eq!(consensus(&[]), None); + assert_eq!(consensus(&[None, None]), None); + let bad = |m: [f32; 3]| consensus(&[Some(m)]); + assert_eq!(bad([0.0, 1.0, 1.5]), None); + assert_eq!(bad([2.0, 0.0, 1.5]), None); + assert_eq!(bad([2.0, 1.0, -1.5]), None); + assert_eq!(bad([f32::NAN, 1.0, 1.5]), None); + assert_eq!(bad([2.0, f32::INFINITY, 1.5]), None); + assert_eq!(bad([2.0, 1.0, f32::NEG_INFINITY]), None); + // mixed: the good ones decide, the missing and the unusable ones are not counted as anything + let mixed = [None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0]), Some([2.2, 1.0, 1.4]), Some([0.0, 0.0, 0.0])]; + assert!(close(consensus(&mixed).unwrap(), [2.1, 1.0, 1.5]), "{:?}", consensus(&mixed)); + assert_eq!(consensus(&[None, Some([2.0, 1.0, 1.6]), None]), Some([2.0, 1.0, 1.6])); + } + + #[test] + fn multipliers_become_the_reciprocal_as_shot_neutral() { + use lightcraft_color::{D50, Xy}; + let tagged = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.9]), as_shot_white_xy: Some(Xy::new(0.34, 0.35)), ..Default::default() }; + let mut color = FrameColor::Camera(Box::new(tagged.clone())); + color.set_as_shot_multipliers([2.0, 1.0, 1.6]); + let FrameColor::Camera(c) = &color else { panic!("still camera colour") }; + assert_eq!(c.as_shot_neutral, Some([0.5, 1.0, 1.0 / f64::from(1.6f32)])); + assert_eq!(c.as_shot_white_xy, None, "a stale AsShotWhiteXY would be read before the neutral"); + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..tagged.clone() }, "nothing else changes"); + // multipliers that aren't usable change nothing + for bad in [[0.0, 1.0, 1.0], [2.0, f32::NAN, 1.6], [2.0, 1.0, f32::INFINITY], [-2.0, 1.0, 1.6]] { + let mut color = FrameColor::Camera(Box::new(ColorData { as_shot_white_xy: Some(D50), ..Default::default() })); + let before = color.clone(); + color.set_as_shot_multipliers(bad); + assert_eq!(color, before, "{bad:?}"); + } + // linear colour has no white balance to set + let mut linear = Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).color; + let before = linear.clone(); + linear.set_as_shot_multipliers([2.0, 1.0, 1.6]); + assert_eq!(linear, before); + } } diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 95750ba0b..af1d6d25b 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -28,7 +28,7 @@ use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::align::{self, Alignment}; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::{MergeError, Progress, Result, check}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -87,6 +87,9 @@ pub struct HdrResult { pub baseline_exposure: f64, /// The reference frame's lens corrections, for `radiance` ([`Frame::lens`]). pub lens: Vec, + /// The inputs' as-shot white-balance multipliers they agree on ([`crate::frame::consensus_multipliers`]), already in + /// `color`; kept so merged brackets stitched into a panorama can vote as frames of their own. + pub wb_multipliers: Option<[f32; 3]>, } /// Long edge (at most) of the frames the deghost test compares: small moving things (a star at 200 mm) still span a few @@ -273,6 +276,12 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) None => Plane::new(0, 0), }; let rf = &frames[reference]; + // the merge's white balance is the median of its inputs', as a panorama's: a CR2's or NEF's colour tags have none + let wb_multipliers = consensus_multipliers(&frames); + let mut color = rf.color.clone(); + if let Some(multipliers) = wb_multipliers { + color.set_as_shot_multipliers(multipliers); + } Ok(HdrResult { radiance: out, ghost, @@ -280,12 +289,13 @@ pub fn merge_hdr(mut frames: Vec, opts: &HdrOptions, progress: &Progress) ev, ev_exif, alignments, - color: rf.color.clone(), + color, raw: rf.raw, orientation: rf.orientation, metadata: rf.metadata.clone(), baseline_exposure: rf.baseline_exposure, lens: rf.lens.clone(), + wb_multipliers, }) } diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index f8b7e7173..2d0a523f4 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -4,8 +4,9 @@ //! - Pixel values are scaled so the brightest pixel sits just below 1.0 (the white level); the //! scale is carried in `BaselineExposure`, so a default rendering looks like the reference frame. //! - Raw sources: the reference frame's DNG colour tags (`ColorMatrix*`, `ForwardMatrix*`, -//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over unchanged — the data is -//! still camera RGB. +//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over — the data is still camera +//! RGB. A raw with its white balance only in vendor multipliers (CR2, NEF…) has none of its own: the +//! result's `AsShotNeutral` comes from the frames' multipliers ([`FrameColor::set_as_shot_multipliers`]). //! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB, //! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1). //! - Lens corrections (an HDR merge's: the reference frame's, [`crate::Frame::lens`]) go in `OpcodeList3`, where the @@ -111,8 +112,10 @@ pub fn write_linear_dng( color: cd, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: lens.to_vec(), ..Default::default() }, metadata: meta, + lens_metadata: Default::default(), }; let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half }; lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).map_err(|e| MergeError::Output(e.to_string())) diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 73d19f515..4211f6503 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; pub use project::Projection; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::linalg; use crate::{MergeError, Progress, Result, check}; use blend::{Blender, Buf, push_pull}; @@ -376,6 +376,11 @@ pub fn stitch(mut frames: Vec, opts: &PanoOptions, progress: &Progress) - image = finish::fill_edges(&image, &alpha); } let r = &frames[used[0]]; + // the panorama's white balance is the median of the frames that are in it, not whichever frame came first + let mut color = r.color.clone(); + if let Some(multipliers) = consensus_multipliers(used.iter().filter_map(|&i| frames.get(i))) { + color.set_as_shot_multipliers(multipliers); + } Ok(PanoResult { image, alpha, @@ -390,7 +395,7 @@ pub fn stitch(mut frames: Vec, opts: &PanoOptions, progress: &Progress) - native_megapixels: (bx1 - bx0) * (by1 - by0) / 1e6, focal: f_out, origin: (ox, oy), - color: r.color.clone(), + color, metadata: r.metadata.clone(), raw: r.raw, baseline_exposure: r.baseline_exposure, diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs new file mode 100644 index 000000000..48acff6af --- /dev/null +++ b/crates/merge/tests/white_balance.rs @@ -0,0 +1,248 @@ +//! A panorama or an HDR merge of raw files keeps their as-shot white balance (issue #1144). +//! +//! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has +//! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast. + +use lightcraft_color::{D50, Xy}; +use lightcraft_geom::Orientation; +use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip}; +use lightcraft_merge::hdr::{HdrOptions, HdrResult, merge_hdr}; +use lightcraft_merge::output::color_data; +use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch}; +use lightcraft_merge::{DngSamples, no_progress, write_linear_dng}; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; +use lightcraft_raw::ColorData; + +type Multipliers = Option<[f32; 3]>; + +/// A camera that sees linear sRGB, with the colour matrices a DNG gives but no white balance of its own, as a raw +/// file whose white balance is only in the vendor multipliers would be after the app has worked its colour out. +fn camera_without_white_balance() -> ColorData { + let srgb = FrameColor::Linear { + to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()), + }; + ColorData { as_shot_neutral: None, ..color_data(&srgb) } +} + +fn raw_frame(image: Rgb32f, color: &ColorData, wb_multipliers: Multipliers) -> Frame { + let clip = detect_clip(&image); + Frame { + image, + clip, + exposure: None, + raw: true, + color: FrameColor::Camera(Box::new(color.clone())), + orientation: Orientation::Normal, + metadata: Metadata::default(), + baseline_exposure: 0.0, + lens: Vec::new(), + wb_multipliers, + } +} + +/// Overlapping views of one scene, one frame per entry of `wb`, in order. +fn shoot(color: &ColorData, wb: &[Multipliers]) -> Vec { + let n = wb.len(); + let yaws: Vec = (0..n).map(|i| (i as f64 - (n as f64 - 1.0) / 2.0) * 25.0).collect(); + let views = lightcraft_merge::synth::pano_views(400, 300, 350.0, &yaws).unwrap(); + views.into_iter().zip(wb).map(|(image, m)| raw_frame(image, color, *m)).collect() +} + +fn pano(frames: Vec) -> PanoResult { + stitch(frames, &PanoOptions { projection: Projection::Cylindrical, ..Default::default() }, &no_progress).unwrap() +} + +fn neutral_of(res: &PanoResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +fn reciprocal(m: [f64; 3]) -> [f64; 3] { + m.map(|v| 1.0 / v) +} + +fn assert_close(got: [f64; 3], want: [f64; 3], tolerance: f64) { + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() <= tolerance), "{got:?} vs {want:?}"); +} + +/// The panorama as a DNG and read back with the repo's own raw reader. +fn dng_of(res: &PanoResult) -> lightcraft_raw::RawImage { + let bytes = write_linear_dng(&res.image, &res.color, Orientation::Normal, &res.metadata, res.baseline_exposure, DngSamples::U16, &[]).unwrap(); + lightcraft_raw::decode(&bytes).unwrap() +} + +const DAYLIGHT: [f32; 3] = [2.0, 1.0, 1.6]; + +#[test] +fn a_panorama_of_an_odd_number_of_raw_frames_has_their_median_white_balance() { + // three frames the camera balanced slightly differently: the middle R/G and the middle B/G, from different frames + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used, [0, 1, 2]); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); +} + +#[test] +fn a_panorama_of_an_even_number_of_frames_takes_the_mean_of_the_middle_two() { + let wb = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 4); + let (r, b) = ((f64::from(2.0f32) + f64::from(2.2f32)) / 2.0, (f64::from(1.6f32) + f64::from(1.5f32)) / 2.0); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([r, 1.0, b]), 1e-6); + // two frames: the mean of both + let res = pano(shoot(&camera_without_white_balance(), &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); +} + +#[test] +fn frames_without_multipliers_do_not_vote() { + // one frame of three has none (a DNG, say): the other two decide + let wb = [Some([2.0, 1.0, 1.6]), None, Some([2.2, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 3); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.1, 1.0, 1.5]), 1e-6); + // a first frame without them doesn't hide the others' + let wb = [None, Some(DAYLIGHT), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // unusable values don't count either + let wb = [Some([f32::NAN, 1.0, 1.0]), Some(DAYLIGHT), Some([0.0, 1.0, 1.4]), Some([2.0, f32::INFINITY, 1.6]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn a_frame_outside_the_panorama_does_not_vote() { + let color = camera_without_white_balance(); + let mut frames = shoot(&color, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)]); + // four photos of other scenes, balanced very differently: counted, they would be the majority + let others = lightcraft_scenes::demo_library().into_iter().filter(|s| s.kind != lightcraft_scenes::Kind::Canyon).take(4); + frames.extend(others.map(|scene| raw_frame(scene.render(400, 300), &color, Some([4.0, 1.0, 0.8])))); + assert_eq!(frames.len(), 7); + let res = pano(frames); + assert_eq!(res.used, [0, 1, 2], "the others don't connect"); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn without_any_multipliers_the_colour_is_the_first_frames_as_before() { + for source in [ + camera_without_white_balance(), + ColorData::default(), + ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }, + ] { + let frames = shoot(&source, &[None, None, None]); + let first = frames[0].color.clone(); + let res = pano(frames); + assert_eq!(res.color, first); + assert_eq!(neutral_of(&res), source.as_shot_neutral); + } + // a DNG's own AsShotWhiteXY survives when no frame brings multipliers + let xy = ColorData { as_shot_white_xy: Some(D50), ..camera_without_white_balance() }; + assert_eq!(pano(shoot(&xy, &[None, None, None])).color, FrameColor::Camera(Box::new(xy))); +} + +#[test] +fn the_consensus_replaces_a_neutral_or_white_point_the_first_frame_had() { + // a neutral the app worked out for the first frame, and a white point of the source: both give way + let had = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.8]), as_shot_white_xy: Some(Xy::new(0.31, 0.33)), ..camera_without_white_balance() }; + let res = pano(shoot(&had, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); + // everything else of the first frame's colour is as it was + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..had }); +} + +#[test] +fn the_dng_says_the_median_white_balance_as_as_shot_neutral() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let frames = shoot(&source, &wb); + let median = consensus_multipliers(&frames).unwrap(); + let res = pano(frames); + let raw = dng_of(&res); + // AsShotNeutral is the reciprocal of the median multipliers, to the precision of a TIFF rational + let got = raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"); + assert_close(got, reciprocal(median.map(f64::from)), 2e-6); + assert_close(got, [0.5, 1.0, 0.625], 2e-6); + assert_eq!(raw.color.as_shot_white_xy, None, "the DNG spec has the two tags exclude each other"); + // what a viewer starts from: the white and the camera transform the source's multipliers give + let want = lightcraft_raw::color::as_shot_white_of(&source, Some(median)); + let xy = lightcraft_raw::color::as_shot_white_xy(&raw); + assert!((xy.x - want.x).abs() < 1e-4 && (xy.y - want.y).abs() < 1e-4, "{xy:?} vs {want:?}"); + let (got, want) = (lightcraft_raw::color::camera_transform(&raw, xy), lightcraft_raw::color::camera_transform_of(&source, want)); + assert!(got.wb.iter().zip(want.wb).all(|(a, b)| (a - b).abs() < 1e-3), "{:?} vs {:?}", got.wb, want.wb); + // the colour model and the picture are the source's, and the same without the colour matrices + assert_eq!(raw.color.forward_matrix[0].is_some(), source.forward_matrix[0].is_some()); + assert_eq!((raw.width, raw.height, raw.cpp), (res.image.width, res.image.height, 3)); + let bare = dng_of(&pano(shoot(&ColorData::default(), &wb))); + assert_close(bare.color.as_shot_neutral.expect("AsShotNeutral without colour matrices"), [0.5, 1.0, 0.625], 2e-6); +} + +#[test] +fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() { + let res = pano(shoot(&ColorData::default(), &[None, None, None])); + let raw = dng_of(&res); + assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None)); +} + +/// Exposures `evs` (EV, relative to the view's) of one view, one frame each, with `wb` as their multipliers. +fn bracket(color: &ColorData, evs: &[f64], wb: &[Multipliers]) -> Vec { + let view = lightcraft_merge::synth::pano_views(320, 240, 280.0, &[0.0]).unwrap().remove(0); + evs.iter() + .zip(wb) + .map(|(ev, m)| { + let k = 2f32.powf(*ev as f32); + let mut frame = raw_frame(view.map(|p| p.map(|v| (v * k).min(1.0))), color, *m); + frame.exposure = Some(*ev); + frame + }) + .collect() +} + +fn hdr(frames: Vec) -> HdrResult { + merge_hdr(frames, &HdrOptions::default(), &no_progress).unwrap() +} + +fn hdr_neutral(res: &HdrResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +#[test] +fn an_hdr_merge_of_raw_frames_has_their_median_white_balance() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some(DAYLIGHT), Some([1.9, 1.0, 1.7])]; + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &wb)); + assert_eq!(res.wb_multipliers, Some(DAYLIGHT)); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // the DNG says it, to the precision of a TIFF rational + let bytes = + write_linear_dng(&res.radiance, &res.color, res.orientation, &res.metadata, res.baseline_exposure, DngSamples::Half, &res.lens).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_close(raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"), [0.5, 1.0, 0.625], 2e-6); + // an even count takes the mean of the middle two; frames without multipliers do not vote + let res = hdr(bracket(&source, &[-1.0, 1.0], &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &[None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0])])); + assert_eq!(res.wb_multipliers, Some([2.0, 1.0, 1.6])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn an_hdr_merge_without_multipliers_keeps_the_reference_colour_as_before() { + let source = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }; + let frames = bracket(&source, &[-2.0, 0.0, 2.0], &[None, None, None]); + let reference = frames[1].color.clone(); + let res = hdr(frames); + assert_eq!((res.reference, res.wb_multipliers), (1, None)); + assert_eq!(res.color, reference); +} diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 5b1706bde..3cb2003b2 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -29,7 +29,11 @@ default = ["jxl"] jxl = ["dep:jxl-oxide"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } proptest = { workspace = true } zune-jpegxl = { workspace = true } # Pinned CC0 corpus identities; not a product dependency. sha2 = { version = "0.10", default-features = false, features = ["std"] } +# Peak-heap regression tests (tests/sr2_heap.rs); not a product dependency. +dhat = { workspace = true } diff --git a/crates/raw/examples/rawinfo.rs b/crates/raw/examples/rawinfo.rs index c1e05bedc..23e540b96 100644 --- a/crates/raw/examples/rawinfo.rs +++ b/crates/raw/examples/rawinfo.rs @@ -227,6 +227,8 @@ mod tests { linearized: false, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, }; let path = dir.path("valid.dng"); std::fs::write(&path, write_dng(&raw, &DngWriteOptions::default()).expect("encode generated fixture")).expect("write fixture"); diff --git a/crates/raw/src/binned.rs b/crates/raw/src/binned.rs index 674651c90..db725a2ec 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -24,7 +24,7 @@ impl RawImage { /// and B; Bayer: `k` even, so that every block has the same layout)? pub fn can_bin(&self, k: usize) -> bool { let Some(cfa) = &self.cfa else { return false }; - if k < 2 || self.cpp != 1 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { + if k < 2 || self.cpp != 1 || (self.pixel_aspect - 1.0).abs() >= 0.01 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { return false; } // every window position relative to the pattern @@ -163,8 +163,10 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index a7cea813d..2caafcfde 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -287,8 +287,10 @@ mod tests { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: Default::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index d97b56215..d9fc27d76 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -8,7 +8,10 @@ //! adaptive luminance / chrominance tolerances (ε_L, ε_C from the paper) — the homogeneity. //! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate (average on ties). //! -//! Work is done in overlapping tiles (bounded memory, rayon-parallel). +//! Work is done in overlapping tiles (bounded memory, rayon-parallel), one band of tiles at a time written +//! straight into the output. Each step runs only where the tile's output depends on it: the choice reads the +//! homogeneity 1 pixel around the tile, which reads Lab 2 pixels around, the candidates read the greens 3 +//! pixels around, and those read the mosaic 5 pixels around, inside the `MARGIN`. use super::Mosaic; use crate::Rgb32f; @@ -23,14 +26,19 @@ pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { if w == 0 || h == 0 { return out; } - // one row of tiles per task, written straight into the picture (collecting the tiles first held a second copy) - out.data.par_chunks_mut(w * TILE).enumerate().for_each(|(ty, band)| { - let (y0, th) = (ty * TILE, band.len() / w); - for x0 in (0..w).step_by(TILE) { - let tw = TILE.min(w - x0); - let px = tile(m, x0, y0, tw, th); - for y in 0..th { - band[y * w + x0..y * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); + // one row of tiles per task, its tiles in parallel too, written straight into the picture + out.data.par_chunks_mut((w * TILE).max(1)).enumerate().for_each(|(ty, band)| { + let (y0, th) = (ty * TILE, band.len() / w.max(1)); + let tiles: Vec<(usize, usize, Vec<[f32; 3]>)> = (0..w.div_ceil(TILE)) + .into_par_iter() + .map(|tx| { + let (x0, tw) = (tx * TILE, TILE.min(w - tx * TILE)); + (x0, tw, tile(m, x0, y0, tw, th)) + }) + .collect(); + for (x0, tw, px) in tiles { + for (row, src) in band.chunks_exact_mut(w).zip(px.chunks_exact(tw)) { + row[x0..x0 + tw].copy_from_slice(src); } } }); @@ -57,110 +65,100 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> let (ox, oy) = (x0 as isize - MARGIN as isize, y0 as isize - MARGIN as isize); let mut mos = vec![0f32; n]; let mut col = vec![0u8; n]; - for y in 0..bh { - for x in 0..bw { - mos[y * bw + x] = m.get(ox + x as isize, oy + y as isize); - col[y * bw + x] = m.color(ox + x as isize, oy + y as isize); + for (y, (mrow, crow)) in mos.chunks_exact_mut(bw).zip(col.chunks_exact_mut(bw)).enumerate() { + let ry = super::reflect(oy + y as isize, m.h); + if ox >= 0 && ox as usize + bw <= m.w { + // inside the image: the row as it is, and its colours repeating with the pattern + let x0 = ry * m.w + ox as usize; + mrow.copy_from_slice(&m.data[x0..x0 + bw]); + let period: Vec = (0..m.cfa.width).map(|k| m.cfa.color_at(ox as usize + k, ry)).collect(); + for (c, &p) in crow.iter_mut().zip(period.iter().cycle()) { + *c = p; + } + } else { + for x in 0..bw { + mrow[x] = m.get(ox + x as isize, oy + y as isize); + crow[x] = m.color(ox + x as isize, oy + y as isize); + } } } - let cl = |x: isize, y: isize| -> usize { y.clamp(0, bh as isize - 1) as usize * bw + x.clamp(0, bw as isize - 1) as usize }; + // flat indices of the pixels `d` or more from the buffer's edge, row by row + let inner = |d: usize| (d..bh - d).flat_map(move |y| y * bw + d..y * bw + bw - d); // 1. directional greens let mut gh = vec![0f32; n]; let mut gv = vec![0f32; n]; - for y in 0..bh as isize { - for x in 0..bw as isize { - let i = cl(x, y); - let c = mos[i]; - if col[i] == 1 { - gh[i] = c; - gv[i] = c; - continue; - } - let (l, r) = (mos[cl(x - 1, y)], mos[cl(x + 1, y)]); - let est = (l + r) * 0.5 + (2.0 * c - mos[cl(x - 2, y)] - mos[cl(x + 2, y)]) * 0.25; - gh[i] = super::clamp_between(est, l, r); - let (u, d) = (mos[cl(x, y - 1)], mos[cl(x, y + 1)]); - let est = (u + d) * 0.5 + (2.0 * c - mos[cl(x, y - 2)] - mos[cl(x, y + 2)]) * 0.25; - gv[i] = super::clamp_between(est, u, d); + for i in inner(MARGIN - 3) { + let c = mos[i]; + if col[i] == 1 { + gh[i] = c; + gv[i] = c; + continue; } + let (l, r) = (mos[i - 1], mos[i + 1]); + let est = (l + r) * 0.5 + (2.0 * c - mos[i - 2] - mos[i + 2]) * 0.25; + gh[i] = super::clamp_between(est, l, r); + let (u, d) = (mos[i - bw], mos[i + bw]); + let est = (u + d) * 0.5 + (2.0 * c - mos[i - 2 * bw] - mos[i + 2 * bw]) * 0.25; + gv[i] = super::clamp_between(est, u, d); } - // 2. full candidates - let complete = |g: &[f32]| -> Vec<[f32; 3]> { + // 2. full candidates, and 3. their Lab + // (`p` with channel `c` set to `v`, in registers: writing channels through an index and reading the pixel + // back for its Lab stalled on store forwarding) + let set = |p: [f32; 3], c: u8, v: f32| [if c == 0 { v } else { p[0] }, if c == 1 { v } else { p[1] }, if c >= 2 { v } else { p[2] }]; + let complete = |g: &[f32]| -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { let mut rgb = vec![[0f32; 3]; n]; - for y in 0..bh as isize { - for x in 0..bw as isize { - let i = cl(x, y); - let own = col[i] as usize; - let gvv = g[i]; - let d = |dx: isize, dy: isize| { - let j = cl(x + dx, y + dy); - mos[j] - g[j] - }; - let p = &mut rgb[i]; - p[1] = gvv; - if own == 1 { - let ch = col[cl(x + 1, y)] as usize; - let cv = col[cl(x, y + 1)] as usize; - p[ch] = gvv + (d(-1, 0) + d(1, 0)) * 0.5; - p[cv] = gvv + (d(0, -1) + d(0, 1)) * 0.5; - } else { - p[own] = mos[i]; - p[2 - own] = gvv + (d(-1, -1) + d(1, -1) + d(-1, 1) + d(1, 1)) * 0.25; - } - } + let mut lab_of = vec![[0f32; 3]; n]; + for i in inner(MARGIN - 2) { + let own = col[i]; + let gvv = g[i]; + let d = |j: usize| mos[j] - g[j]; + let p = if own == 1 { + let p = set([0.0, gvv, 0.0], col[i + 1], gvv + (d(i - 1) + d(i + 1)) * 0.5); + set(p, col[i + bw], gvv + (d(i - bw) + d(i + bw)) * 0.5) + } else { + let p = set([0.0, gvv, 0.0], own, mos[i]); + set(p, 2 - own, gvv + (d(i - bw - 1) + d(i - bw + 1) + d(i + bw - 1) + d(i + bw + 1)) * 0.25) + }; + rgb[i] = p; + lab_of[i] = lab(p); } - rgb + (rgb, lab_of) }; - let rh = complete(&gh); - let rv = complete(&gv); - // 3. Lab + homogeneity - let lh: Vec<[f32; 3]> = rh.iter().map(|&p| lab(p)).collect(); - let lv: Vec<[f32; 3]> = rv.iter().map(|&p| lab(p)).collect(); + let (rh, lh) = complete(&gh); + let (rv, lv) = complete(&gv); + // homogeneity let dl = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).abs(); let dc = |a: &[f32; 3], b: &[f32; 3]| (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2); let mut hh = vec![0u8; n]; let mut hv = vec![0u8; n]; - for y in 1..bh as isize - 1 { - for x in 1..bw as isize - 1 { - let i = cl(x, y); - let (l, r, u, d) = (cl(x - 1, y), cl(x + 1, y), cl(x, y - 1), cl(x, y + 1)); - let eps_l = dl(&lh[i], &lh[l]).max(dl(&lh[i], &lh[r])).min(dl(&lv[i], &lv[u]).max(dl(&lv[i], &lv[d]))); - let eps_c = dc(&lh[i], &lh[l]).max(dc(&lh[i], &lh[r])).min(dc(&lv[i], &lv[u]).max(dc(&lv[i], &lv[d]))); - let mut ch = 0; - let mut cv = 0; - for j in [l, r, u, d] { - if dl(&lh[i], &lh[j]) <= eps_l && dc(&lh[i], &lh[j]) <= eps_c { - ch += 1; - } - if dl(&lv[i], &lv[j]) <= eps_l && dc(&lv[i], &lv[j]) <= eps_c { - cv += 1; - } - } - hh[i] = ch; - hv[i] = cv; - } + for i in inner(MARGIN - 1) { + // left, right, up, down + let (l, r, u, d) = (i - 1, i + 1, i - bw, i + bw); + let (a, b) = (lh[i], lv[i]); + let dlh = [dl(&a, &lh[l]), dl(&a, &lh[r]), dl(&a, &lh[u]), dl(&a, &lh[d])]; + let dch = [dc(&a, &lh[l]), dc(&a, &lh[r]), dc(&a, &lh[u]), dc(&a, &lh[d])]; + let dlv = [dl(&b, &lv[l]), dl(&b, &lv[r]), dl(&b, &lv[u]), dl(&b, &lv[d])]; + let dcv = [dc(&b, &lv[l]), dc(&b, &lv[r]), dc(&b, &lv[u]), dc(&b, &lv[d])]; + let eps_l = dlh[0].max(dlh[1]).min(dlv[2].max(dlv[3])); + let eps_c = dch[0].max(dch[1]).min(dcv[2].max(dcv[3])); + hh[i] = (0..4).map(|k| u8::from(dlh[k] <= eps_l && dch[k] <= eps_c)).sum(); + hv[i] = (0..4).map(|k| u8::from(dlv[k] <= eps_l && dcv[k] <= eps_c)).sum(); } // 4. choose let mut out = Vec::with_capacity(tw * th); - for y in MARGIN..MARGIN + th { - for x in MARGIN..MARGIN + tw { - let (mut sh, mut sv) = (0u32, 0u32); - for dy in -1isize..=1 { - for dx in -1isize..=1 { - let j = cl(x as isize + dx, y as isize + dy); - sh += hh[j] as u32; - sv += hv[j] as u32; - } - } - let i = y * bw + x; - out.push(if sh > sv { - rh[i] - } else if sv > sh { - rv[i] - } else { - [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] - }); + for i in inner(MARGIN) { + let (mut sh, mut sv) = (0u32, 0u32); + for j in [i - bw - 1, i - bw, i - bw + 1, i - 1, i, i + 1, i + bw - 1, i + bw, i + bw + 1] { + sh += hh[j] as u32; + sv += hv[j] as u32; } + out.push(if sh > sv { + rh[i] + } else if sv > sh { + rv[i] + } else { + [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] + }); } out } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 026aae90d..e9fc0999d 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -338,15 +338,34 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result) -> f32 { + match scale { + Some([h, v]) if *h > 0.0 && *v > 0.0 && (1.5..=4.0).contains(&(v / h)) => (v / h) as f32, + _ => 1.0, + } +} + #[cfg(test)] mod tests { + #[test] + fn default_scale_makes_tall_pixels() { + assert_eq!(default_scale_aspect(Some(&[1.0, 2.0])), 2.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[2.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.2])), 1.0); + assert_eq!(default_scale_aspect(None), 1.0); + } + use super::*; /// Store `image` the way DNG 1.7 interleaves it: row `r` of the image is written to stored row diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index ef2c4feec..d7ba42776 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -538,8 +538,10 @@ pub fn write_rendered_dng( color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: crate::OpcodeLists::default(), metadata: metadata.clone(), + lens_metadata: Default::default(), }; let order = ByteOrder::Little; write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) diff --git a/crates/raw/src/lens_metadata.rs b/crates/raw/src/lens_metadata.rs new file mode 100644 index 000000000..4529950c8 --- /dev/null +++ b/crates/raw/src/lens_metadata.rs @@ -0,0 +1,187 @@ +//! Lens-correction data a raw file records, kept with where it came from and separately from what LightCraft +//! applies. +//! +//! A readable table is not a correction. Each component carries a [`SonyLensStatus`]: only +//! [`SonyLensStatus::Applied`] components were converted into the image's `OpcodeList3`, and that is decided by +//! independent validation per model, coding and component (see `docs/sony-lens-corrections.md`), never by the mere +//! presence of a table. Everything else here is metadata for diagnostics and later validation work. + +use crate::Rect; +use serde::{Deserialize, Serialize}; + +/// Vendor lens-correction metadata of a raw file (see the module docs). Empty for formats that record none, or +/// whose tables are read straight into opcodes. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LensMetadata { + /// Sony ARW: the raw image IFD's shading, lateral chromatic aberration and distortion records, with their + /// encrypted `SR2SubIFD` copies. `None` when neither directory holds any of them. + #[serde(default)] + pub sony: Option, +} + +/// Sony's in-file lens-correction record: three independent components, each a camera setting and a radial table. +/// +/// Tag identities and storage types from the ExifTool EXIF / Sony tag-name documentation: raw-IFD `0x7031` / `0x7032` +/// (shading, "VignettingCorrection" / "VignettingCorrParams"), `0x7034` / `0x7035` (lateral chromatic aberration), +/// `0x7036` / `0x7037` (distortion), and the `SR2SubIFD` tables `0x797d`, `0x7980`, `0x7982`. The `SR2SubIFD` setting +/// tags `0x797c`, `0x797f`, `0x7981` are unnamed there; they sit next to the tables and equal the raw-IFD settings +/// in every file inspected. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensCorrections { + /// Shading (vignetting): 16 radial samples whose gain function, radius convention and processing stage are not + /// established. Opaque signed data. + pub shading: SonyComponent<[i16; 16]>, + /// Lateral chromatic aberration: two 16-sample radial curves. A public analysis reads them as red, then blue, + /// relative to green; that is not validated here. + pub chromatic_aberration: SonyComponent<[[i16; 16]; 2]>, + /// Distortion: 16 radial samples (see `docs/sony-lens-corrections.md` for the validated interpretation). + pub distortion: SonyComponent<[i16; 16]>, + /// Whether the encrypted `SR2SubIFD` was found and decrypted. `false`: no copies to fall back on or compare. + pub sr2_read: bool, + /// The framing and coding the tables have to be interpreted against. + pub geometry: SonyLensGeometry, +} + +/// One correction component: the camera's setting, its table, and whether LightCraft applies it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyComponent { + pub setting: SonyField, + /// The table's samples after the leading sample count, which must equal their number. + pub table: SonyField, + pub status: SonyLensStatus, +} + +/// One field read from the raw IFD and from the `SR2SubIFD`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyField { + /// The value in use: the raw IFD's when it holds a well-formed one, else the `SR2SubIFD`'s when the raw IFD + /// lacks the tag. `None` when no copy is usable, and when the raw IFD's copy is malformed: a corrupt field is + /// reported, not silently replaced by the other copy. The raw IFD's copy is the one the TIFF reader that decodes + /// the image parsed (its first occurrence, within the reader's limits). + pub value: Option, + pub source: Option, + pub raw_ifd: SonyCopy, + pub sr2: SonyCopy, + /// Whether the two copies are equal, when both are well-formed. The raw IFD's wins when they differ. + pub copies_agree: Option, + /// Diagnostics: the tag occurs more than once in the raw IFD / the `SR2SubIFD`. Only the first occurrence is used, + /// as the TIFF reader does (the raw IFD's: the first one whose values it could read). + #[serde(default)] + pub raw_ifd_repeated: bool, + #[serde(default)] + pub sr2_repeated: bool, +} + +/// Where a value was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySource { + /// The plain raw image IFD. + RawIfd, + /// The encrypted `SR2SubIFD`. + Sr2SubIfd, +} + +/// How one copy of a field was found. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyCopy { + /// Not in that directory (or the `SR2SubIFD` could not be read). + Absent, + /// Present with exactly the supported type and layout. + Valid, + /// Present, but refused. + Rejected(SonyFieldError), +} + +/// Why a present field was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyFieldError { + /// Another TIFF field type than SSHORT (8) for a table, or SHORT (3) / SSHORT for a setting. + FieldType(u16), + /// Another number of values than the supported layout's: 17 (shading, distortion), 33 (CA) or 1 (setting). + Count(u64), + /// The leading sample count is not the layout's 16 (32 for CA). Older bodies store 11 (22) samples padded to the + /// same length, a layout that is not interpreted; a count of 0 goes with an "unavailable" setting. + CountPrefix(i16), + /// The values lie outside the file (raw IFD) or the decrypted block (`SR2SubIFD`). + OutsideData, + /// In the raw IFD but past what the TIFF reader that decodes the image reads (8,192 entries per directory, a + /// total value budget), so the image doesn't use it either. + PastReaderLimits, + /// A setting stored as a negative SSHORT. + NegativeSetting(i16), +} + +/// A camera correction setting: the stored number and its documented meaning. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SonySetting { + pub value: u16, + pub meaning: SonySettingMeaning, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation). Values are compared whole; +/// they are not read as bit masks. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySettingMeaning { + /// Shading 256, CA 0, distortion 0. The table is still recorded: Off does not mean absent. + Off, + /// Shading 257 or 272, CA 1, distortion 1. + Auto, + /// Distortion 17: Auto, fixed by the lens. + ForcedAuto, + /// "No correction params available": shading 511, CA 255, distortion 255. + Unavailable, + /// Any other value (e.g. a shading "Low" setting, whose number is not documented). Never treated as Off. + Unknown(u16), +} + +/// Whether a component is applied, and if not, why. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyLensStatus { + /// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A and ILCE-7RM6 Bayer distortion in native 3:2 framing). + Applied, + /// No usable table. + NoTable, + /// Decoded only: no independent validation covers this component's rendering semantics on any body (shading, + /// lateral CA). + NotValidatedComponent, + /// Decoded only: the component is validated on other camera models, not on this one. + NotValidatedModel, + /// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a + /// table found only in the `SR2SubIFD`. + NotValidatedMode, + /// Validated scope, but the table is all zeros: a valid no-op, nothing to apply. + Neutral, + /// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate + /// crop, or a fitted map that misses the table or folds). + Rejected, +} + +/// The framing and coding evidence for interpreting the tables, all in raw-frame pixels. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensGeometry { + /// The stored raw frame, including any padding (lossless L files are padded). + pub storage_width: usize, + pub storage_height: usize, + /// Sony's crop tags (`0x74c7` / `0x74c8`) as recorded. + pub sony_crop: Option, + /// The raw IFD's `DefaultCropOrigin` / `DefaultCropSize` as recorded. + pub default_crop: Option, + /// The image's crop (the decoder's precedence: Sony's tags, then the default crop, then the recorded size). + pub image_crop: Rect, + /// The rectangle the validated distortion conversion is centred on and normalised to: the default crop when + /// the file has one, else the image crop. Clipped to the frame. + pub reference: Rect, + /// TIFF compression of the raw image (1 uncompressed, 7 lossless, 32767 ARW2, 32766 ARW6). + pub compression: u16, + /// Downsized lossless M/S data (linear YCbCr, not sensor CFA). + pub linear_ycbcr: bool, + /// Both the image crop and the reference are 3:2 within 0.5 %, the only framing validated. + pub native_3_2: bool, +} + +impl SonyField { + /// Whether either directory holds the tag (well-formed or not). + pub fn is_present(&self) -> bool { + self.raw_ifd != SonyCopy::Absent || self.sr2 != SonyCopy::Absent + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 3a7f04a95..202983331 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -35,6 +35,7 @@ pub mod highlight; #[cfg(feature = "jxl")] mod jxl; pub mod kelvin_presets; +pub mod lens_metadata; pub mod ljpeg; pub(crate) mod llvc; pub mod opcodes; @@ -51,6 +52,7 @@ mod wb_estimate; pub use demosaic::{Method, demosaic}; pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng}; +pub use lens_metadata::LensMetadata; pub use lightcraft_color::Mat3; pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; @@ -430,13 +432,30 @@ pub struct RawInfo { pub wb_multipliers: Option<[f32; 3]>, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]). + #[serde(default)] + pub lens_metadata: LensMetadata, + /// See [`RawImage::pixel_aspect`]. + #[serde(default = "square_pixels")] + pub pixel_aspect: f32, +} + +fn square_pixels() -> f32 { + 1.0 +} + +/// Rows of a picture `height` rows tall once its pixels (taller than wide by `aspect`) are made square. +pub fn stretched_height(height: usize, aspect: f32) -> usize { + if (aspect - 1.0).abs() < 0.01 { height } else { ((height as f32 * aspect).round() as usize).max(1) } } impl RawInfo { - /// Size of the developed image before orientation: the default crop, else the active area. + /// Size of the developed image before orientation: the default crop, else the active area, with the rows + /// stretched for non-square pixels. pub fn developed_size(&self) -> (usize, usize) { let c = self.crop.clipped(self.active_area.width, self.active_area.height); - if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) } + let (w, h) = if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) }; + (w, stretched_height(h, self.pixel_aspect)) } } @@ -661,8 +680,16 @@ pub struct RawImage { pub wb_multipliers: Option<[f32; 3]>, /// Whether a `LinearizationTable` was applied to `data`. pub linearized: bool, + /// Height of a sensor pixel over its width (1.0 for square pixels). The D1X and D1H write 2.0: their frame is + /// 4028 x 1324 and the picture is 3:2 only once the rows are doubled. [`Self::develop`] stretches the rows by + /// this factor. + pub pixel_aspect: f32, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]); only + /// validated components are in `opcodes`. + #[serde(default)] + pub lens_metadata: LensMetadata, } /// Black-subtracted, white-normalised data covering the active area. @@ -694,6 +721,8 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes.clone(), metadata: self.metadata.clone(), + lens_metadata: self.lens_metadata.clone(), + pixel_aspect: self.pixel_aspect, } } @@ -713,6 +742,8 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes, metadata: self.metadata, + lens_metadata: self.lens_metadata, + pixel_aspect: self.pixel_aspect, } } @@ -816,10 +847,13 @@ impl RawImage { pub fn finish_demosaiced(&self, mut rgb: Rgb32f) -> Rgb32f { opcodes::apply_list3(&self.opcodes.list3, &mut rgb); let c = self.develop_crop(rgb.width, rgb.height); - if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { + let rgb = + if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { rgb } else { rgb.into_crop(c.x, c.y, c.width, c.height) }; + let h = stretched_height(rgb.height, self.pixel_aspect); + if h == rgb.height { return rgb; } - rgb.into_crop(c.x, c.y, c.width, c.height) + lightcraft_raster::resample::resize(&rgb, rgb.width, h, lightcraft_raster::resample::Filter::Mitchell) } /// The part of a `width × height` demosaiced picture (the active area) that [`Self::develop`] keeps: the default @@ -878,8 +912,10 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), }; let direct = raw.develop(Method::Bilinear).unwrap(); let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); @@ -894,6 +930,17 @@ mod tests { // a crop that pokes out is clipped to the picture whole.crop = Rect::new(6, 4, 10, 10); assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); + + // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so + let mut tall = raw.clone(); + tall.pixel_aspect = 2.0; + let d = tall.develop(Method::Bilinear).unwrap(); + assert_eq!((d.width, d.height), (5, 8)); + assert_eq!(tall.info().developed_size(), (5, 8)); + assert_eq!(raw.info().developed_size(), (5, 4)); + assert!(!tall.can_bin(2)); + assert_eq!(stretched_height(1324, 2.0), 2648); + assert_eq!(stretched_height(1324, 1.0), 1324); } /// A maker-note crop (`PEF` AOC / `ORF` ImageProcessing) with an origin of `u64::MAX` used to @@ -920,6 +967,8 @@ mod tests { linearized: false, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, }; assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image"); assert!(raw.normalized().is_err()); diff --git a/crates/raw/src/llvc.rs b/crates/raw/src/llvc.rs index 0e2cc39cc..17408c760 100644 --- a/crates/raw/src/llvc.rs +++ b/crates/raw/src/llvc.rs @@ -14,6 +14,7 @@ fn corrupt(why: &str) -> RawError { } /// One bit, failing when the reader has run past the `source_bits` of its half. +#[inline(always)] fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result { let b = bits.get(1); if bits.consumed_bits() > source_bits { @@ -24,12 +25,14 @@ fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result { /// Count zero bits up to and including the terminating 1 (the `0^n 1` codes), bounded by `MAX_DPT`. fn zeros_then_one(bits: &mut Bits<'_>, source_bits: usize) -> Result { - let mut n = 0; - while bit(bits, source_bits)? == 0 { - n += 1; - if n > MAX_DPT { - return Err(corrupt("DPT code exceeds MAX_DPT")); - } + // the next MAX_DPT + 1 bits at once (more zeros than that is corrupt either way) + let n = bits.peek(MAX_DPT + 1).leading_zeros() - (32 - (MAX_DPT + 1)); + bits.skip((n + 1).min(MAX_DPT + 1)); + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + if n > MAX_DPT { + return Err(corrupt("DPT code exceeds MAX_DPT")); } Ok(n) } @@ -42,7 +45,7 @@ pub(crate) fn vld_decode_line(bits: &mut Bits<'_>, source_bits: usize, width: us let sets = width.div_ceil(4); let (mut abs, mut dpt, mut cnt, mut dpts) = ([0u32; 4], 0u32, sets, 0u8); let (mut abs_v, mut abs_e, mut dpt_v, mut dpt_e, mut zr) = (false, false, true, false, 0u64); - let mut out: Vec = Vec::new(); + let mut out: Vec = Vec::with_capacity(sets * 4); loop { // DPT value decoding (Figure 6.6). if cnt == 0 { @@ -180,43 +183,80 @@ pub(crate) fn band_rows(n: usize, phase: u8) -> (usize, usize) { (low, n - low) } -/// `v[i]` with `i` clamped into range (the whole-sample symmetric extension of the band); 0 for an empty band. -fn at(v: &[i32], i: isize) -> i32 { - let last = v.len() as isize - 1; - if last < 0 { - return 0; - } - v.get(i.clamp(0, last) as usize).copied().unwrap_or(0) -} - /// Saturating narrowing: hostile coefficients clamp instead of overflowing. fn sat(v: i64) -> i32 { v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 } -/// 1-D inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes). -fn inverse_1d(l: &[i32], h: &[i32], phase: u8) -> Result> { - let (n, p) = (l.len() + h.len(), isize::from(phase)); - if band_rows(n, phase) != (l.len(), h.len()) || phase > 1 { - return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); +/// Sample `i` (`w` values wide) of a band of `n` samples, clamped into range (the whole-sample symmetric extension); +/// `zeros` for an empty band. +#[inline] +fn band_sample<'a>(v: &'a [i32], w: usize, n: usize, i: isize, zeros: &'a [i32]) -> &'a [i32] { + if n == 0 { + return zeros; } - let even: Vec = l - .iter() - .enumerate() - .map(|(j, &lv)| sat(i64::from(lv) - ((i64::from(at(h, j as isize - 1 + p)) + i64::from(at(h, j as isize + p)) + 2) >> 2))) - .collect(); - let mut x = vec![0; n]; - let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); - for (xv, &e) in x.iter_mut().skip(lo).step_by(2).zip(&even) { - *xv = e; - } - for (k, (xv, &hv)) in x.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { - *xv = sat(i64::from(hv) + ((i64::from(at(&even, k as isize - p)) + i64::from(at(&even, k as isize + 1 - p))) >> 1)); - } - Ok(x) + let i = i.clamp(0, n as isize - 1) as usize; + v.get(i * w..(i + 1) * w).unwrap_or(zeros) } -/// Gathers the lines of the `low`/`high` pair along one axis (columns when `vertical`), transforms them, and scatters back. +/// Inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes), of a +/// signal whose samples are `w` values wide: a band's width for all its columns at once (row by row, as each column +/// alone would give; rows in parallel). One line (`w = 1`) goes through [`lift_row`]. +fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result> { + use rayon::prelude::*; + let w = w.max(1); + let (nl, nh) = (l.len() / w, h.len() / w); + let (n, p) = (nl + nh, isize::from(phase)); + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + let zeros = vec![0; w]; + let mut even = vec![0; nl * w]; + even.par_chunks_exact_mut(w).zip(l.par_chunks_exact(w)).enumerate().for_each(|(j, (e, lv))| { + let (a, b) = (band_sample(h, w, nh, j as isize - 1 + p, &zeros), band_sample(h, w, nh, j as isize + p, &zeros)); + for (((e, &lv), &a), &b) in e.iter_mut().zip(lv).zip(a).zip(b) { + *e = sat(i64::from(lv) - ((i64::from(a) + i64::from(b) + 2) >> 2)); + } + }); + let mut out = vec![0; n * w]; + let lo = usize::from(phase); + out.par_chunks_exact_mut(w).enumerate().for_each(|(t, o)| { + if t % 2 == lo { + if let Some(e) = even.get((t - lo) / 2 * w..((t - lo) / 2 + 1) * w) { + o.copy_from_slice(e); + } + return; + } + let k = (t - (1 - lo)) / 2; + let Some(hv) = h.get(k * w..(k + 1) * w) else { return }; + let (a, b) = (band_sample(&even, w, nl, k as isize - p, &zeros), band_sample(&even, w, nl, k as isize + 1 - p, &zeros)); + for (((o, &hv), &a), &b) in o.iter_mut().zip(hv).zip(a).zip(b) { + *o = sat(i64::from(hv) + ((i64::from(a) + i64::from(b)) >> 1)); + } + }); + Ok(out) +} + +/// [`inverse_lift`] of one line (`w = 1`) into `out` (`l.len() + h.len()` values), without allocating: `even` is +/// scratch space. The caller has checked the band sizes. +fn lift_row(l: &[i32], h: &[i32], phase: u8, even: &mut Vec, out: &mut [i32]) { + let (nl, nh, p) = (l.len(), h.len(), isize::from(phase)); + let hs = |i: isize| if nh == 0 { 0 } else { h.get(i.clamp(0, nh as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + even.clear(); + even.extend(l.iter().enumerate().map(|(j, &lv)| sat(i64::from(lv) - ((hs(j as isize - 1 + p) + hs(j as isize + p) + 2) >> 2)))); + let even = &*even; + let es = |i: isize| if nl == 0 { 0 } else { even.get(i.clamp(0, nl as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); + for (o, &e) in out.iter_mut().skip(lo).step_by(2).zip(even.iter()) { + *o = e; + } + for (k, (o, &hv)) in out.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { + *o = sat(i64::from(hv) + ((es(k as isize - p) + es(k as isize + 1 - p)) >> 1)); + } +} + +/// Inverse transform of the `low`/`high` pair along one axis: down the columns when `vertical` (all at once, row by +/// row), else along each row. fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result { if !low.ok() || !high.ok() { return Err(RawError::Corrupt("ARW6 wavelet: plane size mismatch".into())); @@ -228,20 +268,22 @@ fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result let (nl, nh) = if vertical { (low.height, high.height) } else { (low.width, high.width) }; let n = nl + nh; let (w, h) = if vertical { (across_l, n) } else { (n, across_l) }; - let mut out = Plane::zeros(w, h); - let get = |p: &Plane, i: usize| -> Vec { - if vertical { p.data.iter().skip(i).step_by(p.width.max(1)).copied().collect() } else { p.row(i).map(<[i32]>::to_vec).unwrap_or_default() } - }; - for i in 0..across_l { - let x = inverse_1d(&get(low, i), &get(high, i), phase)?; - if vertical { - for (o, v) in out.data.iter_mut().skip(i).step_by(w.max(1)).zip(x) { - *o = v; - } - } else if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { - r.copy_from_slice(&x); - } + if across_l == 0 { + return Ok(Plane::zeros(w, h)); } + if vertical { + return Ok(Plane { width: w, height: h, data: inverse_lift(&low.data, &high.data, w, phase)? }); + } + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + use rayon::prelude::*; + let mut out = Plane::zeros(w, h); + out.data.par_chunks_exact_mut(w.max(1)).enumerate().for_each_init(Vec::new, |even, (i, r)| { + if let (Some(l), Some(hi)) = (low.row(i), high.row(i)) { + lift_row(l, hi, phase, even, r); + } + }); Ok(out) } @@ -448,6 +490,29 @@ mod tests { assert_eq!(inverse_53_1d_rows(&col(&[3, -4]), &col(&[10, 20, 30]), 1).unwrap().data, vec![5, -5, 9, -17, 13]); } #[test] + fn horizontal_pass_gives_the_vertical_passs_samples() { + // two implementations of one lifting: `lift_row` along rows, `inverse_lift` down columns + let mut s = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s % 8191) as i32 - 4095 + }; + for n in 1..12usize { + for phase in 0..2u8 { + let ((nl, nh), rows) = (band_rows(n, phase), 3); + let low = Plane { width: nl, height: rows, data: (0..nl * rows).map(|_| next()).collect() }; + let high = Plane { width: nh, height: rows, data: (0..nh * rows).map(|_| next()).collect() }; + let across = inverse_53_1d_cols(&low, &high, phase).unwrap(); + for r in 0..rows { + let down = inverse_53_1d_rows(&col(low.row(r).unwrap()), &col(high.row(r).unwrap()), phase).unwrap(); + assert_eq!(across.row(r).unwrap(), &down.data[..], "n {n}, phase {phase}, row {r}"); + } + } + } + } + #[test] fn inverse_53_rejects_mismatched_bands() { assert!(inverse_53_1d_rows(&col(&[1, 2, 3]), &col(&[1]), 0).is_err()); assert!(inverse_53_1d_rows(&col(&[1, 2]), &Plane::zeros(2, 2), 0).is_err()); diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs index 566c2b1c3..7ca6b0e38 100644 --- a/crates/raw/src/profile.rs +++ b/crates/raw/src/profile.rs @@ -123,6 +123,177 @@ impl HsvTable { }; hsv_to_rgb(hue + shift, s2, v2) } + + /// [`Self::apply`] to `N` colours at once (`rgb[channel][lane]`), bit for bit: the same operations, with the + /// branches on the data (largest channel, hue sector, wrap-around) turned into selects so the compiler + /// vectorises them. The table reads, `powf` and the rare `rem_euclid` run lane by lane. + pub fn apply_lanes(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let [r, g, b] = rgb; + if !self.fits_lanes() { + let mut out = rgb; + for l in 0..N { + [out[0][l], out[1][l], out[2][l]] = self.apply([r[l], g[l], b[l]]); + } + return out; + } + // `active`: the colours the table applies to (`apply` returns black, NaN and infinite ones as they are) + let (mut max, mut active, mut first) = ([0.0f32; N], [true; N], [false; N]); + let (mut d, mut ratio, mut second, mut third) = ([0.0f32; N], [0.0f32; N], [0.0f32; N], [0.0f32; N]); + for l in 0..N { + let m = r[l].max(g[l]).max(b[l]); + active[l] = !(m.is_nan() | (m <= 0.0) | !m.is_finite()); + d[l] = m - r[l].min(g[l]).min(b[l]); + // hue_of, for each largest channel + ratio[l] = (g[l] - b[l]) / d[l]; + second[l] = (b[l] - r[l]) / d[l] + 2.0; + third[l] = (r[l] - g[l]) / d[l] + 4.0; + first[l] = active[l] & (d[l] > 0.0) & (m == r[l]); + max[l] = m; + } + let ratio = wrap_lanes(ratio, 6.0, first); + let (mut hue, mut sat) = ([0.0f32; N], [0.0f32; N]); + for l in 0..N { + let h = if max[l] == r[l] { + ratio[l] + } else if max[l] == g[l] { + second[l] + } else { + third[l] + }; + let h = h * 60.0; + hue[l] = if d[l] <= 0.0 { 0.0 } else { h }; + sat[l] = d[l] / max[l]; + } + let mut v_index = max; + if self.srgb_value { + for l in 0..N { + if active[l] { + v_index[l] = linear_to_srgb(max[l].min(1.0)); + } + } + } + let [shift, ss, vs] = self.lookup_lanes(&hue, &sat, &v_index, active); + let (mut s2, mut v2, mut curve) = ([0.0f32; N], [0.0f32; N], [false; N]); + for l in 0..N { + s2[l] = limited_select(sat[l], ss[l]); + v2[l] = if self.srgb_value { max[l] } else { limited_select(max[l], vs[l]) }; + curve[l] = self.srgb_value & active[l] & !((max[l] <= 1.0) & (vs[l] == 1.0)); + } + for l in 0..N { + if curve[l] { + v2[l] = if max[l] <= 1.0 { srgb_to_linear((v_index[l] * vs[l]).min(1.0)) } else { max[l] * srgb_to_linear(vs[l].min(1.0)).min(1.0) }; + } + } + // hsv_to_rgb + let hue = wrap_lanes(std::array::from_fn(|l| hue[l] + shift[l]), 360.0, active); + let mut out = rgb; + for l in 0..N { + let hp = hue[l] / 60.0; + // `(hp as u32).min(5) as f32` for every `hp`, NaN included + let step = |k: f32| if hp >= k { 1.0 } else { 0.0 }; + let sector = step(1.0) + step(2.0) + step(3.0) + step(4.0) + step(5.0); + let f = hp - sector; + let (s, v) = (s2[l], v2[l]); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + let red = if (sector == 0.0) | (sector == 5.0) { + v + } else if sector == 1.0 { + q + } else if sector == 4.0 { + t + } else { + p + }; + let green = if sector == 0.0 { + t + } else if sector <= 2.0 { + v + } else if sector == 3.0 { + q + } else { + p + }; + let blue = if sector <= 1.0 { + p + } else if sector == 2.0 { + t + } else if sector == 5.0 { + q + } else { + v + }; + out[0][l] = if active[l] { red } else { r[l] }; + out[1][l] = if active[l] { green } else { g[l] }; + out[2][l] = if active[l] { blue } else { b[l] }; + } + out + } + + /// Whether [`Self::lookup_lanes`] can do this table's index arithmetic in `f32`: every index below 2²³. + fn fits_lanes(&self) -> bool { + let entries = self.hue_divisions.checked_mul(self.sat_divisions).and_then(|n| n.checked_mul(self.val_divisions.max(1))); + self.hue_divisions >= 1 && self.sat_divisions >= 1 && entries.is_some_and(|n| n <= 1 << 23) + } + + /// [`Self::lookup`] at `N` points (`(shift, saturation scale, value scale)[lane]`), bit for bit for the lanes + /// `wanted`, on a table that [`Self::fits_lanes`]. The indices are computed as `f32` (exact integers below 2²³: + /// `x as usize` is a saturating conversion that doesn't vectorise). + fn lookup_lanes(&self, h: &[f32; N], s: &[f32; N], v: &[f32; N], wanted: [bool; N]) -> [[f32; N]; 3] { + const EXACT: f32 = 8_388_608.0; // 2²³: ulp 1 from here + // `x as usize` for `0 ≤ x < 2²³`: rounded to an integer, minus 1 if it rounded up; NaN is 0 + let floor = |x: f32| { + let nearest = (x + EXACT) - EXACT; + let floor = if nearest > x { nearest - 1.0 } else { nearest }; + if x.is_nan() { 0.0 } else { floor } + }; + let (hn, sn, vn) = (self.hue_divisions as f32, self.sat_divisions as f32, self.val_divisions as f32); + let axis = |x: f32, n: f32| { + let f = x.clamp(0.0, 1.0) * (n - 1.0); + let i0 = floor(f).min((n - 2.0).max(0.0)); + let i1 = (i0 + 1.0).min(n - 1.0); + (i0, i1, (f - i0).clamp(0.0, 1.0)) + }; + let h = wrap_lanes(*h, 360.0, wanted); + // the corners' indices (v, h, s), s fastest, and the weights between them + let (mut at, mut t) = ([[0u32; N]; 8], [[0.0f32; N]; 3]); + for l in 0..N { + let hf = (h[l] / 360.0 * hn).clamp(0.0, hn); + let h0 = floor(hf).min(hn - 1.0); + // (h0 + 1) % hn + let h1 = if h0 + 1.0 == hn { 0.0 } else { h0 + 1.0 }; + let (s0, s1, ts) = axis(s[l], sn); + let (v0, v1, tv) = axis(v[l], vn); + let (v0, v1, tv) = if self.val_divisions > 1 { (v0, v1, tv) } else { (0.0, 0.0, 0.0) }; + for (k, (vi, hi)) in [(v0, h0), (v0, h1), (v1, h0), (v1, h1)].into_iter().enumerate() { + let row = (vi * hn + hi) * sn; + // an exact integer below 2²³ plus 2²³ has it as its mantissa + at[2 * k][l] = (row + s0 + EXACT).to_bits().wrapping_sub(EXACT.to_bits()); + at[2 * k + 1][l] = (row + s1 + EXACT).to_bits().wrapping_sub(EXACT.to_bits()); + } + [t[0][l], t[1][l], t[2][l]] = [(hf - h0).clamp(0.0, 1.0), ts, tv]; + } + let mut corner = [[[0.0f32; N]; 3]; 8]; + for l in 0..N { + for (corner, at) in corner.iter_mut().zip(&at) { + [corner[0][l], corner[1][l], corner[2][l]] = self.data.get(at[l] as usize).copied().unwrap_or([0.0, 1.0, 1.0]); + } + } + let lerp = |a: &[f32; N], b: &[f32; N], t: &[f32; N]| { + let mut out = [0.0f32; N]; + for l in 0..N { + out[l] = a[l] + (b[l] - a[l]) * t[l]; + } + out + }; + let [th, ts, tv] = &t; + let mut out = [[0.0f32; N]; 3]; + for (c, out) in out.iter_mut().enumerate() { + let plane = |k: usize| lerp(&lerp(&corner[k][c], &corner[k + 1][c], ts), &lerp(&corner[k + 2][c], &corner[k + 3][c], ts), th); + // `lookup`'s `v0 == v1` for every lane: `axis` gives `v1 = v0 + 1` whenever there are two values or more + *out = if self.val_divisions > 1 { lerp(&plane(0), &plane(4), tv) } else { plane(0) }; + } + out + } } /// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1). @@ -145,6 +316,37 @@ fn wrap(x: f32, p: f32) -> f32 { } } +/// [`wrap`] of `N` values, bit for bit for the lanes `wanted`: branch-free but for the `rem_euclid` fallback. +#[inline] +fn wrap_lanes(x: [f32; N], p: f32, wanted: [bool; N]) -> [f32; N] { + let (mut out, mut slow) = ([0.0f32; N], [false; N]); + for l in 0..N { + let (first, below, second) = ((x[l] >= 0.0) & (x[l] < p), (x[l] > -p) & (x[l] < 0.0), (x[l] >= p) & (x[l] < 2.0 * p)); + let (up, down) = (x[l] + p, x[l] - p); + out[l] = if first { + x[l] + } else if below { + up + } else { + down + }; + slow[l] = wanted[l] & !(first | below | second); + } + for l in 0..N { + if slow[l] { + out[l] = x[l].rem_euclid(p); + } + } + out +} + +/// [`limited`] without a branch. +#[inline] +fn limited_select(x: f32, k: f32) -> f32 { + let (below, above) = ((x * k).min(1.0), x * k.min(1.0)); + if x <= 1.0 { below } else { above } +} + fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { if d <= 0.0 { return 0.0; @@ -330,6 +532,81 @@ mod tests { } } + /// Every combination of awkward components (zero, negative, tiny, around 1, huge, NaN, ±infinity), and random + /// colours from black to above 1: grey, each channel largest, hues either side of the wrap-around at 0°. + fn awkward_colours() -> Vec<[f32; 3]> { + let special = [0.0, -0.0, 1e-30, -1e-30, 1e-45, 0.003_130_8, 0.5, -0.5, 1.0, 1.0f32.next_up(), 2.0, 1e30, -1e30]; + let special = [&special[..], &[f32::MAX, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]].concat(); + let mut out = Vec::new(); + for a in &special { + for b in &special { + out.extend(special.iter().map(|c| [*a, *b, *c])); + } + } + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..40_000 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let scale = [1.0, 1e-4, 8.0, 1e-38][(seed >> 56) as usize & 3]; + let c: [f32; 3] = std::array::from_fn(|i| (((seed >> (16 * i)) & 0xffff) as f32 / 65535.0 * 1.6 - 0.2) * scale); + out.push(match seed >> 60 { + 0 => [c[0]; 3], + 1 => [c[0].abs() + 0.1, c[1], c[1].next_up()], + 2 => [c[0].abs() + 0.1, c[1], c[1].next_down()], + _ => c, + }); + } + out + } + + /// `apply_lanes` against `apply`, bit for bit, `N` colours at a time (the last batch padded). + fn assert_lanes_match(t: &HsvTable, colours: &[[f32; 3]]) { + for chunk in colours.chunks(N) { + let rgb: [[f32; N]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.25, |p| p[c]))); + let out = t.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], t.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut table = |h: usize, s: usize, v: usize, srgb_value: bool, shift: f32, value_scale: bool| { + let data = (0..h * s * v) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let x = |k: u64| ((seed >> k) & 0xffff) as f32 / 65535.0; + [(x(0) * 2.0 - 1.0) * shift, 0.5 + x(16), if value_scale { 0.5 + x(32) } else { 1.0 }] + }) + .collect(); + HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value } + }; + let colours = awkward_colours(); + // a fitted camera look's shape; with value scales; a 2D DNG map turning hues by up to 5 turns; one hue and + // one saturation + for t in [ + table(72, 5, 5, true, 30.0, false), + table(72, 5, 5, true, 30.0, true), + table(90, 30, 1, false, 1800.0, true), + table(1, 1, 3, true, 30.0, true), + ] { + assert_lanes_match::<16>(&t, &colours); + assert_lanes_match::<5>(&t, &colours); + } + // entries missing (the identity fallback); too big for the lanes' index arithmetic (one by one) + let short = HsvTable { data: vec![[10.0, 1.5, 0.8]; 7], ..identity(4, 3, 2) }; + let huge = HsvTable { data: Vec::new(), ..identity(4096, 4096, 1) }; + for t in [short, huge] { + assert_lanes_match::<16>(&t, &colours); + } + } + fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) } diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs index aaa0a6dff..16768b16d 100644 --- a/crates/raw/src/saturation.rs +++ b/crates/raw/src/saturation.rs @@ -135,8 +135,10 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 49543c4fe..a662799ae 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -45,8 +45,9 @@ //! through the generic TIFF path. use super::arw6; +use crate::lens_metadata::SonyLensGeometry; use crate::tiffraw::{Packing, check_image, read_image_in}; -use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +use crate::{BlackLevel, Cfa, ColorData, LensMetadata, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes}; use lightcraft_tiff::tags::{self as t, photometric}; @@ -553,6 +554,14 @@ fn dng_default_crop(raw: &Ifd, w: usize, h: usize) -> Option { None } +/// A rectangle exactly as an origin tag and a size tag record it (two values each), when both exist and fit `usize`. +fn recorded_rect(raw: &Ifd, origin: u16, size: u16) -> Option { + let (Some(origin), Some(size)) = (raw.u64s(origin), raw.u64s(size)) else { return None }; + let (&[x, y], &[w, h]) = (origin.as_slice(), size.as_slice()) else { return None }; + let n = |v: u64| usize::try_from(v).ok(); + Some(Rect::new(n(x)?, n(y)?, n(w)?, n(h)?)) +} + /// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style /// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]). /// @@ -749,6 +758,39 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); metadata.height = Some(crop.height as u32); + // The lens-correction record. The distortion table is centred on the DNG-style default crop when the file has + // one: on the ILCE-7RM4A it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and + // the warp was validated against Sony's exports with that centre. The opcode's centre is in active-area + // coordinates, so it stays on the optical centre whichever crop frames the image. + let reference = dng_default_crop(raw, w, h).unwrap_or(crop); + let geometry = SonyLensGeometry { + storage_width: w, + storage_height: h, + sony_crop: recorded_rect(raw, CROP_TOP_LEFT, CROP_SIZE), + default_crop: recorded_rect(raw, t::DEFAULT_CROP_ORIGIN, t::DEFAULT_CROP_SIZE), + image_crop: crop, + reference, + compression: info.compression, + linear_ycbcr: linear_rgb, + native_3_2: super::arw_lens::is_3_2(crop) && super::arw_lens::is_3_2(reference), + }; + // decrypted for the copies' agreement check too, not only when a plain field is missing: a few dozen kB, found by + // reading only three `SR2Private` entries (see `sr2::SubIfd::read`) + let (mut sony_lens, warp) = super::arw_lens::corrections(bytes, tiff.order, (raw, tiff.bigtiff), sr2(), &model, geometry); + // The ILCE-7RM6's tables follow other conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]); + // its allowlist and `arw_lens`'s are disjoint, so at most one of them produces an opcode. Both the image framing + // and the table geometry must stay within the validated 3:2 scope. + let rm6: Vec = if linear_rgb || !super::arw_lens::is_3_2(crop) { + Vec::new() + } else { + super::arw_lens_rm6::lens_opcodes(raw, &model, Rect::new(0, 0, w, h), reference) + }; + if !rm6.is_empty() + && let Some(record) = sony_lens.as_mut() + { + // distortion only: its lateral CA planes are off (see `arw_lens_rm6::lens_opcodes`) + record.distortion.status = crate::lens_metadata::SonyLensStatus::Applied; + } let img = RawImage { format: RawFormat::Arw, width: w, @@ -768,28 +810,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, - // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A - // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was - // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so - // it stays on the optical centre whichever crop frames the image. The ILCE-7RM6's tables follow other - // conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]); the two allowlists are - // disjoint, so at most one of them produces an opcode. - opcodes: OpcodeLists { - // DefaultCrop can retain native geometry while Sony's tags select an aspect crop. - // Both the image framing and the table geometry must stay within the validated 3:2 scope. - list3: if linear_rgb || crop.height == 0 || (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { - Vec::new() - } else { - let geometry = dng_default_crop(raw, w, h).unwrap_or(crop); - let active = Rect::new(0, 0, w, h); - super::arw_lens::distortion(&model, raw, active, geometry) - .into_iter() - .chain(super::arw_lens_rm6::lens_opcodes(raw, &model, active, geometry)) - .collect() - }, - ..Default::default() - }, + pixel_aspect: 1.0, + // only validated components become opcodes (see `arw_lens`, and `arw_lens_rm6` for the ILCE-7RM6) + opcodes: OpcodeLists { list3: warp.into_iter().chain(rm6).collect(), ..Default::default() }, metadata, + lens_metadata: LensMetadata { sony: sony_lens }, }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs index 46af909dd..9837f3e7b 100644 --- a/crates/raw/src/vendor/arw6.rs +++ b/crates/raw/src/vendor/arw6.rs @@ -329,9 +329,10 @@ type HalfLines = (Half, Vec>); fn decode_stream(tile: &[u8], st: &StreamRef, ntu: usize, w2: usize, layouts: &[HalfRows]) -> Result> { let halves = stream_halves(tile, st, ntu)?; let width = line_width(st.group, w2); + // halves are independent bit streams: decode them in parallel halves - .into_iter() - .zip(layouts) + .into_par_iter() + .zip(layouts.par_iter()) .map(|(half, rows)| { let end = half.start.checked_add(half.len).ok_or_else(|| corrupt("half position"))?; let bytes = tile.get(half.start..end).ok_or_else(|| corrupt("half outside the tile"))?; @@ -468,36 +469,36 @@ pub(crate) fn colour(p: &TilePlanes) -> Result> { } let mw = w2.checked_mul(2).ok_or_else(|| corrupt("tile size"))?; let total = len.checked_mul(4).ok_or_else(|| corrupt("tile size"))?; - let at = |q: &Plane, j: usize, x: usize| -> i64 { q.data.get(j * w2 + x).copied().unwrap_or(0) as i64 }; + // (rows as slices: `at` is 0 past a row's end, as never happens once the sizes above match) + let at = |r: &[i32], x: usize| i64::from(r.get(x).copied().unwrap_or(0)); let mut g1 = vec![0i64; len]; - for j in 0..h2 { - let jp = j.saturating_sub(1); - for x in 0..w2 { + g1.par_chunks_mut(w2.max(1)).enumerate().for_each(|(j, row)| { + let (Some(m), Some(rp), Some(rj)) = (p.m.row(j), p.res.row(j.saturating_sub(1)), p.res.row(j)) else { return }; + for (x, g) in row.iter_mut().enumerate() { let xn = (x + 1).min(w2 - 1); - let r = at(&p.res, jp, x) + at(&p.res, jp, xn) + at(&p.res, j, x) + at(&p.res, j, xn); - if let Some(g) = g1.get_mut(j * w2 + x) { - *g = at(&p.m, j, x) - ((r + 4) >> 3); - } + let r = at(rp, x) + at(rp, xn) + at(rj, x) + at(rj, xn); + *g = at(m, x) - ((r + 4) >> 3); } - } - let g = |j: usize, x: usize| g1.get(j * w2 + x).copied().unwrap_or(0); + }); + let g1_row = |j: usize| g1.get(j * w2..(j + 1) * w2).unwrap_or(&[]); + let g = |r: &[i64], x: usize| r.get(x).copied().unwrap_or(0); let mut out = vec![0u16; total]; - for j in 0..h2 { - let jn = (j + 1).min(h2 - 1); - for x in 0..w2 { + // each plane row gives two mosaic rows + out.par_chunks_mut((2 * mw).max(1)).enumerate().for_each(|(j, rows)| { + let (gj, gn) = (g1_row(j), g1_row((j + 1).min(h2 - 1))); + let (Some(res), Some(c1), Some(c2), Some((top, bottom))) = (p.res.row(j), p.c1.row(j), p.c2.row(j), rows.split_at_mut_checked(mw)) else { + return; + }; + for (x, (t, b)) in top.as_chunks_mut::<2>().0.iter_mut().zip(bottom.as_chunks_mut::<2>().0.iter_mut()).enumerate() { let xp = x.saturating_sub(1); - let g2 = ((g(j, xp) + g(j, x) + g(jn, xp) + g(jn, x)) >> 2) + at(&p.res, j, x); - let mean = (g(j, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; - let r = 2 * at(&p.c1, j, x) + mean; - let b = 2 * at(&p.c2, j, x) + mean; - let cells = [(0, r), (1, g(j, x)), (mw, g2), (mw + 1, b)]; - for (off, v) in cells { - if let Some(o) = out.get_mut(2 * j * mw + 2 * x + off) { - *o = curve(v.clamp(0, 4095) as i32); - } + let g2 = ((g(gj, xp) + g(gj, x) + g(gn, xp) + g(gn, x)) >> 2) + at(res, x); + let mean = (g(gj, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; + let cells = [2 * at(c1, x) + mean, g(gj, x), g2, 2 * at(c2, x) + mean]; + for (o, v) in t.iter_mut().chain(b.iter_mut()).zip(cells) { + *o = curve(v.clamp(0, 4095) as i32); } } - } + }); Ok(out) } diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index f4e02d69d..aa7313e23 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -1,28 +1,348 @@ -//! Sony's plain raw-IFD distortion table, expressed as a DNG warp for the shared optics pipeline. +//! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of +//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and the one +//! validated conversion, the ILCE-7RM4A's distortion table, into a DNG warp for the shared optics pipeline. //! -//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7037`, signed shorts). -//! Geometry inferred independently from files and Sony Imaging Edge exports; see docs/sony-lens-corrections.md. -//! No lens database or external decoder code. The unrelated MakerNote table has a different scale. +//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and +//! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags +//! `0x797c`, `0x797f` and `0x7981` sit next to those tables, unnamed there; they equal the raw IFD's settings in every +//! file inspected. Geometry inferred independently from files and Sony Imaging Edge exports; see +//! docs/sony-lens-corrections.md. No lens database and no external decoder code. The MakerNote's similarly named +//! tables have another scale and layout and are never read here. +//! +//! A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses (its first occurrence, within +//! the reader's limits). The directory is also scanned, for diagnostics only: whether a tag is repeated, and why an +//! entry the reader left out was dropped (values outside the file, an unknown type, or past the reader's limits). +//! `SR2SubIFD` fields are read straight from their first entry. Only the exact layouts are accepted: SHORT\[1\] (or +//! nonnegative SSHORT\[1\]) settings, SSHORT\[17\] headed by 16 for shading and distortion, SSHORT\[33\] headed by +//! 32 for CA. Nothing is allocated from a count. The raw IFD's copy wins; the `SR2SubIFD`'s is used only when the raw +//! IFD lacks the tag, and a malformed raw-IFD copy is reported, not replaced. Decoding grants nothing: only components +//! whose interpretation was independently validated for the model and mode are converted ([`SonyLensStatus`]), and +//! the conversion receives exactly the table the decoder applied before this record existed. +use super::sr2::SubIfd; +use crate::lens_metadata::{ + SonyComponent, SonyCopy, SonyField, SonyFieldError, SonyLensCorrections, SonyLensGeometry, SonyLensStatus, SonySetting, SonySettingMeaning, + SonySource, +}; use crate::{Opcode, Rect}; -use lightcraft_tiff::{Ifd, Value}; +use lightcraft_tiff::{ByteOrder, FieldType, Ifd, Value}; -const DISTORTION: u16 = 0x7037; +/// Each component's setting and table tags, `(raw IFD, SR2SubIFD)`. +const SHADING: Tags = Tags { setting: (0x7031, 0x797c), table: (0x7032, 0x797d) }; +const CA: Tags = Tags { setting: (0x7034, 0x797f), table: (0x7035, 0x7980) }; +const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x7982) }; +/// TIFF field types. +const SHORT: u16 = 3; +const SSHORT: u16 = 8; +/// Camera models whose raw-IFD distortion table was independently validated. Exact names: ILCE-7RM4 and ILCE-7RM4A +/// are distinct models, not interchangeable aliases. +const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"]; +/// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one. +const MAX_ENTRIES: usize = 1 << 16; const SAMPLES: usize = 1024; const MAX_ERROR: f64 = 0.0005; -/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts -/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md. -/// A camera setting of Off does not remove the lens data: the user can enable it in the developer. -pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option { +#[derive(Clone, Copy)] +struct Tags { + setting: (u16, u16), + table: (u16, u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Shading, + Ca, + Distortion, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation), compared whole. +fn meaning(kind: Kind, value: u16) -> SonySettingMeaning { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + match (kind, value) { + (Shading, 256) | (Ca | Distortion, 0) => Off, + (Shading, 257 | 272) | (Ca | Distortion, 1) => Auto, + (Distortion, 17) => ForcedAuto, + (Shading, 511) | (Ca | Distortion, 255) => Unavailable, + _ => Unknown(value), + } +} + +/// One copy of a field. +#[derive(Clone, Debug, PartialEq)] +enum Read { + Absent, + Valid(T), + Rejected(SonyFieldError), +} + +impl Read { + fn map(self, f: impl FnOnce(T) -> U) -> Read { + match self { + Read::Absent => Read::Absent, + Read::Valid(v) => Read::Valid(f(v)), + Read::Rejected(e) => Read::Rejected(e), + } + } + fn copy(&self) -> SonyCopy { + match self { + Read::Absent => SonyCopy::Absent, + Read::Valid(_) => SonyCopy::Valid, + Read::Rejected(e) => SonyCopy::Rejected(*e), + } + } +} + +/// One copy of a field, and whether its directory holds the tag more than once. +struct Found { + read: Read, + repeated: bool, +} + +impl Found { + fn map(self, f: impl FnOnce(T) -> U) -> Found { + Found { read: self.read.map(f), repeated: self.repeated } + } +} + +/// A TIFF directory whose out-of-line values sit at `offset - base` in `data`: the raw IFD in the file (`base` 0), or +/// the decrypted `SR2SubIFD`, whose offsets are file offsets into the block itself. +struct Directory<'a> { + /// From the entry count on. + entries: &'a [u8], + data: &'a [u8], + base: usize, + order: ByteOrder, + /// BigTIFF layout: 8-byte entry count, 20-byte entries with an 8-byte count and value field. + big: bool, +} + +impl<'a> Directory<'a> { + /// The first entry of `tag`, and whether another follows. Only the first counts, as in the TIFF reader and + /// `sr2.rs`. Reads at most the entries the data holds, and at most [`MAX_ENTRIES`]. + fn entry(&self, tag: u16) -> (Option<&'a [u8]>, bool) { + let o = self.order; + let (n, head, size) = if self.big { (o.read_u64(self.entries, 0), 8, 20) } else { (o.read_u16(self.entries, 0).map(u64::from), 2, 12) }; + let Some(n) = n else { return (None, false) }; + let mut found = None; + for i in 0..usize::try_from(n).unwrap_or(usize::MAX).min(MAX_ENTRIES) { + let at = i.checked_mul(size).and_then(|at| at.checked_add(head)); + let Some(e) = at.and_then(|at| self.entries.get(at..at.checked_add(size)?)) else { break }; + if o.read_u16(e, 0) == Some(tag) { + if found.is_some() { + return (found, true); + } + found = Some(e); + } + } + (found, false) + } + + /// Field type and count of entry `e`. + fn field(&self, e: &[u8]) -> (u16, u64) { + let o = self.order; + let count = if self.big { o.read_u64(e, 4) } else { o.read_u32(e, 4).map(u64::from) }; + (o.read_u16(e, 2).unwrap_or(0), count.unwrap_or(u64::MAX)) + } + + /// `len` value bytes of entry `e`: inline when they fit the value field, else at the entry's offset. + fn values(&self, e: &'a [u8], len: usize) -> Option<&'a [u8]> { + let (field, width) = if self.big { (12, 8) } else { (8, 4) }; + if len <= width { + return e.get(field..field + len); + } + let offset = if self.big { self.order.read_u64(e, field)? } else { u64::from(self.order.read_u32(e, field)?) }; + let at = usize::try_from(offset).ok()?.checked_sub(self.base)?; + self.data.get(at..at.checked_add(len)?) + } + + /// Why the TIFF reader left out entry `e`, which the directory holds: a field type it doesn't know, values outside + /// the data, or else its own limits (the entries it reads per directory, its value budget). + fn unread(&self, e: &'a [u8]) -> SonyFieldError { + let (kind, count) = self.field(e); + let Some(size) = FieldType::from_u16(kind).map(FieldType::size) else { return SonyFieldError::FieldType(kind) }; + match count.checked_mul(size as u64).and_then(|len| usize::try_from(len).ok()).and_then(|len| self.values(e, len)) { + Some(_) => SonyFieldError::PastReaderLimits, + None => SonyFieldError::OutsideData, + } + } + + /// A radial table of exactly `N` samples in entry `e`: SSHORT\[N + 1\], the first value `N`. + fn table(&self, e: &'a [u8]) -> Read<[i16; N]> { + let (kind, count) = self.field(e); + if kind != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(kind)); + } + if count != (N + 1) as u64 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(bytes) = self.values(e, 2 * (N + 1)) else { return Read::Rejected(SonyFieldError::OutsideData) }; + let at = |i: usize| self.order.read_u16(bytes, 2 * i).map_or(0, |v| v as i16); + let prefix = at(0); + if usize::try_from(prefix).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(prefix)); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) + } + + /// A setting in entry `e`: SHORT\[1\], or SSHORT\[1\] when not negative. + fn setting(&self, e: &'a [u8], kind: Kind) -> Read { + let (field_type, count) = self.field(e); + if field_type != SHORT && field_type != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(field_type)); + } + if count != 1 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(value) = self.values(e, 2).and_then(|b| self.order.read_u16(b, 0)) else { + return Read::Rejected(SonyFieldError::OutsideData); + }; + if field_type == SSHORT && (value as i16) < 0 { + return Read::Rejected(SonyFieldError::NegativeSetting(value as i16)); + } + Read::Valid(SonySetting { value, meaning: meaning(kind, value) }) + } +} + +/// A table as the TIFF reader parsed it: SSHORT\[N + 1\], the first value `N`. +fn parsed_table(value: &Value) -> Read<[i16; N]> { + let Value::SShort(v) = value else { return Read::Rejected(SonyFieldError::FieldType(value.field_type() as u16)) }; + if v.len() != N + 1 { + return Read::Rejected(SonyFieldError::Count(v.len() as u64)); + } + let at = |i: usize| v.get(i).copied().unwrap_or(0); + if usize::try_from(at(0)).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(at(0))); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) +} + +/// A setting as the TIFF reader parsed it: SHORT\[1\], or SSHORT\[1\] when not negative (the documented and the stored +/// type differ). +fn parsed_setting(value: &Value, kind: Kind) -> Read { + let number = match value { + Value::Short(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::SShort(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::Short(v) => v.first().copied(), + Value::SShort(v) => match v.first().copied() { + Some(n) if n < 0 => return Read::Rejected(SonyFieldError::NegativeSetting(n)), + n => n.map(|n| n as u16), + }, + other => return Read::Rejected(SonyFieldError::FieldType(other.field_type() as u16)), + }; + number.map_or(Read::Rejected(SonyFieldError::Count(0)), |value| Read::Valid(SonySetting { value, meaning: meaning(kind, value) })) +} + +/// The raw IFD's copy of `tag`: the one the TIFF reader parsed, which is what the image itself uses. The directory +/// itself only adds diagnostics: whether the tag is repeated, and why an entry the reader left out was dropped. +fn raw_copy(ifd: &Ifd, dir: Option<&Directory<'_>>, tag: u16, read: impl Fn(&Value) -> Read) -> Found { + let (entry, repeated) = dir.map_or((None, false), |d| d.entry(tag)); + let read = match (ifd.get(tag), entry.zip(dir)) { + (Some(parsed), _) => read(&parsed.value), + (None, Some((e, d))) => Read::Rejected(d.unread(e)), + (None, None) => Read::Absent, + }; + Found { read, repeated } +} + +/// The `SR2SubIFD`'s copy of `tag`, read straight from its first entry. +fn sr2_copy<'a, T>(dir: Option<&Directory<'a>>, tag: u16, read: impl Fn(&Directory<'a>, &'a [u8]) -> Read) -> Found { + let Some(d) = dir else { return Found { read: Read::Absent, repeated: false } }; + let (entry, repeated) = d.entry(tag); + Found { read: entry.map_or(Read::Absent, |e| read(d, e)), repeated } +} + +/// One field from its two copies: the raw IFD's when well-formed, else the `SR2SubIFD`'s when the raw IFD lacks the +/// tag. +fn combine(raw: Found, sr2: Found) -> SonyField { + let (raw_ifd, sr2_copy) = (raw.read.copy(), sr2.read.copy()); + let copies_agree = match (&raw.read, &sr2.read) { + (Read::Valid(a), Read::Valid(b)) => Some(a == b), + _ => None, + }; + let (value, source) = match (raw.read, sr2.read) { + (Read::Valid(v), _) => (Some(v), Some(SonySource::RawIfd)), + (Read::Absent, Read::Valid(v)) => (Some(v), Some(SonySource::Sr2SubIfd)), + _ => (None, None), + }; + SonyField { value, source, raw_ifd, sr2: sr2_copy, copies_agree, raw_ifd_repeated: raw.repeated, sr2_repeated: sr2.repeated } +} + +/// Whether `r` has the native 3:2 framing, within 0.5 %. +pub(super) fn is_3_2(r: Rect) -> bool { + r.height > 0 && (r.width as f64 / r.height as f64 - 1.5).abs() <= 0.005 +} + +/// Decode the file's record and the validated distortion warp. `raw` is the raw image IFD as the TIFF reader parsed +/// it (in a BigTIFF file when `big`), `sr2` the decrypted `SR2SubIFD`. The record is `None` when neither directory +/// holds any of the six fields. +pub(super) fn corrections( + bytes: &[u8], + order: ByteOrder, + (raw, big): (&Ifd, bool), + sr2: Option<&SubIfd>, + model: &str, + geometry: SonyLensGeometry, +) -> (Option, Option) { + let plain = usize::try_from(raw.offset).ok().and_then(|at| Some(Directory { entries: bytes.get(at..)?, data: bytes, base: 0, order, big })); + let encrypted = sr2.map(|s| { + let (block, start) = s.block(); + Directory { entries: block, data: block, base: start, order: s.order(), big: false } + }); + let (p, s) = (plain.as_ref(), encrypted.as_ref()); + // CA: two contiguous 16-sample curves after the count + let split = |v: [i16; 32]| -> [[i16; 16]; 2] { std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) }; + let table16 = |tags: (u16, u16)| combine(raw_copy(raw, p, tags.0, parsed_table::<16>), sr2_copy(s, tags.1, |d, e| d.table::<16>(e))); + let shading_table = table16(SHADING.table); + let distortion_table = table16(DISTORTION.table); + let ca_table = combine(raw_copy(raw, p, CA.table.0, parsed_table::<32>).map(split), sr2_copy(s, CA.table.1, |d, e| d.table::<32>(e)).map(split)); + let setting = |tags: Tags, kind: Kind| { + combine(raw_copy(raw, p, tags.setting.0, |v| parsed_setting(v, kind)), sr2_copy(s, tags.setting.1, |d, e| d.setting(e, kind))) + }; + let settings = [setting(SHADING, Kind::Shading), setting(CA, Kind::Ca), setting(DISTORTION, Kind::Distortion)]; + if !shading_table.is_present() && !ca_table.is_present() && !distortion_table.is_present() && !settings.iter().any(SonyField::is_present) { + return (None, None); + } + let [shading_setting, ca_setting, distortion_setting] = settings; + // Only the validated scope reaches the conversion: the raw IFD's table, as the TIFF reader parsed it, of a Bayer + // image whose image crop and reference are native 3:2 (DefaultCrop can keep native geometry while Sony's tags + // select an aspect crop). + let validated_model = DISTORTION_MODELS.contains(&model); + let in_mode = !geometry.linear_ycbcr && geometry.native_3_2 && distortion_table.source == Some(SonySource::RawIfd); + let (w, h) = (geometry.storage_width, geometry.storage_height); + let warp = match &distortion_table.value { + Some(table) if in_mode => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), + _ => None, + }; + let distortion_status = match &distortion_table.value { + None => SonyLensStatus::NoTable, + Some(_) if warp.is_some() => SonyLensStatus::Applied, + Some(_) if !validated_model => SonyLensStatus::NotValidatedModel, + Some(_) if !in_mode => SonyLensStatus::NotValidatedMode, + Some(table) if table.iter().all(|v| *v == 0) => SonyLensStatus::Neutral, + Some(_) => SonyLensStatus::Rejected, + }; + // No independent validation of shading or CA semantics exists on any body: decoded, never applied. + let unvalidated = |present: bool| if present { SonyLensStatus::NotValidatedComponent } else { SonyLensStatus::NoTable }; + let record = SonyLensCorrections { + shading: SonyComponent { status: unvalidated(shading_table.value.is_some()), setting: shading_setting, table: shading_table }, + chromatic_aberration: SonyComponent { status: unvalidated(ca_table.value.is_some()), setting: ca_setting, table: ca_table }, + distortion: SonyComponent { setting: distortion_setting, table: distortion_table, status: distortion_status }, + sr2_read: sr2.is_some(), + geometry, + }; + (Some(record), warp) +} + +/// The validated conversion of a raw-IFD distortion table into a `WarpRectilinear`. Only independently validated +/// camera models and the 16-entry layout; unknown models and implausible or non-smooth maps are left uncorrected. +/// See docs/sony-lens-corrections.md. A camera setting of Off does not remove the lens data: the user can enable it +/// in the developer. +fn distortion(model: &str, table: &[i16; 16], active: Rect, crop: Rect) -> Option { // A matching table shape does not establish its scale or radial normalization on another body. - // Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. - if model != "ILCE-7RM4A" { + if !DISTORTION_MODELS.contains(&model) { return None; } - let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None }; - let [16, table @ ..] = values.as_slice() else { return None }; - let table: &[i16; 16] = table.try_into().ok()?; if table.iter().all(|v| *v == 0) || table.iter().any(|v| !(-4096..=4096).contains(v)) { return None; } @@ -122,22 +442,720 @@ fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { #[cfg(test)] mod tests { use super::*; - use lightcraft_tiff::Entry; + use crate::lens_metadata::SonyCopy::{Absent, Rejected, Valid}; + use crate::lens_metadata::SonyFieldError::*; + use crate::{LensMetadata, Mode, RawImage}; + use lightcraft_tiff::ByteOrder::{Big, Little}; + use lightcraft_tiff::{IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; - fn ifd(value: Value) -> Ifd { - Ifd { entries: vec![Entry { tag: DISTORTION, value, offset: 0 }], ..Default::default() } - } - fn table(values: &[i16]) -> Ifd { - ifd(Value::SShort(std::iter::once(16).chain(values.iter().copied()).collect())) - } const TELE: [i16; 16] = [-2, 1, 7, 17, 31, 48, 68, 93, 120, 152, 187, 225, 265, 309, 356, 403]; const WIDE: [i16; 16] = [14, 0, -27, -59, -106, -158, -223, -291, -367, -440, -519, -593, -667, -731, -792, -839]; const AREA: Rect = Rect { x: 0, y: 0, width: 9504, height: 6336 }; + /// The shading samples of the public ILCE-7CR samples (raw.pixls.us DSC00795/6/8). + const SHADING_T: [i16; 16] = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + + /// A CA table with negative and positive samples and two different curves. + fn ca() -> [i16; 32] { + std::array::from_fn(|i| (i as i16 - 16) * 128) + } + fn ca_curves() -> [[i16; 16]; 2] { + let v = ca(); + std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) + } + fn sshort(prefix: i16, samples: &[i16]) -> Value { + Value::SShort(std::iter::once(prefix).chain(samples.iter().copied()).collect()) + } + /// The six raw-IFD fields: shading 272 (Auto), CA 1 (Auto), and the distortion `setting` and `table`. + fn plain(table: &[i16; 16], setting: u16) -> Vec<(u16, Value)> { + vec![ + (SHADING.setting.0, Value::Short(vec![272])), + (SHADING.table.0, sshort(16, &SHADING_T)), + (CA.setting.0, Value::Short(vec![1])), + (CA.table.0, sshort(32, &ca())), + (DISTORTION.setting.0, Value::Short(vec![setting])), + (DISTORTION.table.0, sshort(16, table)), + ] + } + /// The same fields as `SR2SubIFD` entries `(tag, type, values)`. + fn encrypted(table: &[i16; 16], setting: u16) -> Vec<(u16, u16, Vec)> { + let t = |prefix: i16, v: &[i16]| std::iter::once(prefix).chain(v.iter().copied()).map(|x| x as u16).collect::>(); + vec![ + (SHADING.setting.1, SHORT, vec![272]), + (SHADING.table.1, SSHORT, t(16, &SHADING_T)), + (CA.setting.1, SHORT, vec![1]), + (CA.table.1, SSHORT, t(32, &ca())), + (DISTORTION.setting.1, SHORT, vec![setting]), + (DISTORTION.table.1, SSHORT, t(16, table)), + ] + } + fn set(fields: &mut Vec<(u16, Value)>, tag: u16, value: Value) { + fields.retain(|(t, _)| *t != tag); + fields.push((tag, value)); + } + fn word(order: ByteOrder, v: u32) -> Vec { + let mut out = Vec::new(); + order.put_u32(&mut out, v); + out + } + + /// A small uncompressed Sony CFA file (the raw IFD is IFD0) with the lens fields `plain` in its raw IFD and, when + /// `sr2` is set, an encrypted `SR2SubIFD` holding those entries, reached as Sony stores it: `DNGPrivateData` -> + /// `SR2Private` IFD -> block. + struct Arw { + model: &'static str, + order: ByteOrder, + bigtiff: bool, + size: (u32, u32), + sony_crop: Option<[u32; 4]>, + default_crop: Option<[u32; 4]>, + plain: Vec<(u16, Value)>, + sr2: Option)>>, + /// The key stored in `SR2SubIFDKey`; the block is always encrypted with the usual one. + stored_key: [u8; 4], + /// An edit to the decrypted `SR2SubIFD` before it is encrypted. + sr2_patch: Option>, + } + + impl Arw { + fn new(model: &'static str, order: ByteOrder) -> Arw { + Arw { + model, + order, + bigtiff: false, + size: (32, 24), + sony_crop: Some([2, 2, 30, 20]), + default_crop: Some([2, 2, 30, 20]), + plain: plain(&TELE, 0), + sr2: Some(encrypted(&TELE, 0)), + stored_key: KEY, + sr2_patch: None, + } + } + + fn build(&self) -> Vec { + let (w, h) = self.size; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(self.model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + if let Some([x, y, cw, ch]) = self.sony_crop { + raw.set(0x74c7, Value::Long(vec![x, y])); + raw.set(0x74c8, Value::Long(vec![cw, ch])); + } + if let Some([x, y, cw, ch]) = self.default_crop { + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![x, y])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![cw, ch])); + } + if self.sr2.is_some() { + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + } + for (tag, value) in &self.plain { + raw.set(*tag, value.clone()); + } + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![0; (w * h * 2) as usize]] }); + let mut file = TiffWriter::new(self.order, self.bigtiff).write(&[raw]).unwrap(); + let Some(entries) = &self.sr2 else { return file }; + let o = self.order; + let at = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + let private = file.len(); + let start = private + 2 + 3 * 12 + 4; + let refs: Vec<(u16, u16, &[u16])> = entries.iter().map(|(tag, kind, v)| (*tag, *kind, v.as_slice())).collect(); + let mut block = super::super::sr2::tests::block(start, &refs, o); + if let Some(patch) = &self.sr2_patch { + super::super::sr2::tests::encrypt(&mut block); + patch(&mut block); + super::super::sr2::tests::encrypt(&mut block); + } + o.put_u16(&mut file, 3); + for (tag, value) in [(0x7200u16, start as u32), (0x7201, block.len() as u32)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, 4); + o.put_u32(&mut file, 1); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend(self.stored_key); + o.put_u32(&mut file, 0); + file.extend(block); + file[at..at + 4].copy_from_slice(&word(o, private as u32)); + file + } + } + + /// Byte position of IFD0's entry for `tag` in a classic TIFF `file`. + fn entry_at(file: &[u8], order: ByteOrder, tag: u16) -> usize { + let ifd = order.read_u32(file, 4).unwrap() as usize; + let n = usize::from(order.read_u16(file, ifd).unwrap()); + (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| order.read_u16(file, e) == Some(tag)).unwrap() + } + + /// Header and full decode of `file`, which must describe the same image; the full one. + fn decode(file: &[u8]) -> RawImage { + let header = super::super::arw::decode(file, Mode::Header).unwrap(); + let full = super::super::arw::decode(file, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + full + } + fn sony(img: &RawImage) -> &SonyLensCorrections { + img.lens_metadata.sony.as_ref().expect("a Sony lens record") + } + /// (has a value, source, raw-IFD copy, SR2SubIFD copy, agreement) of one field. + fn prov(f: &SonyField) -> (bool, Option, SonyCopy, SonyCopy, Option) { + (f.value.is_some(), f.source, f.raw_ifd, f.sr2, f.copies_agree) + } + /// [`prov`] of the three settings, then the three tables. + fn provenance(s: &SonyLensCorrections) -> [(bool, Option, SonyCopy, SonyCopy, Option); 6] { + [ + prov(&s.shading.setting), + prov(&s.chromatic_aberration.setting), + prov(&s.distortion.setting), + prov(&s.shading.table), + prov(&s.chromatic_aberration.table), + prov(&s.distortion.table), + ] + } + + #[test] + fn decodes_all_three_components_with_settings_and_provenance() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + for bigtiff in [false, true] { + for (model, warps, distortion_status) in + [("ILCE-7CR", 0, SonyLensStatus::NotValidatedModel), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] + { + let case = format!("{model} {order:?} bigtiff={bigtiff}"); + let mut arw = Arw::new(model, order); + arw.bigtiff = bigtiff; + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(img.opcodes.list3.len(), warps, "{case}"); + assert!(s.sr2_read, "{case}"); + assert_eq!(provenance(s), [(true, raw, Valid, Valid, Some(true)); 6], "{case}"); + assert_eq!(s.shading.table.value, Some(SHADING_T), "{case}"); + assert_eq!(s.chromatic_aberration.table.value, Some(ca_curves()), "{case}"); + assert_eq!(s.distortion.table.value, Some(TELE), "{case}"); + assert_eq!(s.shading.setting.value, Some(SonySetting { value: 272, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.chromatic_aberration.setting.value, Some(SonySetting { value: 1, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + // readable shading and CA tables are not corrections + assert_eq!(s.shading.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.chromatic_aberration.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.distortion.status, distortion_status, "{case}"); + let crop = Rect::new(2, 2, 30, 20); + let geometry = SonyLensGeometry { + storage_width: 32, + storage_height: 24, + sony_crop: Some(crop), + default_crop: Some(crop), + image_crop: crop, + reference: crop, + compression: 1, + linear_ycbcr: false, + native_3_2: true, + }; + assert_eq!(s.geometry, geometry, "{case}"); + // the applied warp is the validated conversion of the raw IFD's table, as before + let reference: Vec = distortion(model, &TELE, Rect::new(0, 0, 32, 24), crop).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference, "{case}"); + // DNG export keeps the warp and invents no Sony record + let dng = crate::write_dng(&img, &Default::default()).unwrap(); + let back = crate::decode(&dng).unwrap(); + assert_eq!(back.opcodes.list3, img.opcodes.list3, "{case}"); + assert_eq!(back.lens_metadata, LensMetadata::default(), "{case}"); + } + } + } + } + + #[test] + fn settings_keep_their_number_and_unknown_values_are_never_off() { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + for (kind, value, expected) in [ + (Shading, 256, Off), + (Shading, 257, Auto), + (Shading, 272, Auto), + (Shading, 511, Unavailable), + (Shading, 0, Unknown(0)), + (Shading, 1, Unknown(1)), + (Shading, 255, Unknown(255)), + (Shading, 258, Unknown(258)), + (Shading, 273, Unknown(273)), + (Ca, 0, Off), + (Ca, 1, Auto), + (Ca, 255, Unavailable), + (Ca, 2, Unknown(2)), + (Ca, 17, Unknown(17)), + (Ca, 256, Unknown(256)), + (Distortion, 0, Off), + (Distortion, 1, Auto), + (Distortion, 17, ForcedAuto), + (Distortion, 255, Unavailable), + (Distortion, 2, Unknown(2)), + (Distortion, 16, Unknown(16)), + (Distortion, 256, Unknown(256)), + (Distortion, u16::MAX, Unknown(u16::MAX)), + ] { + assert_eq!(meaning(kind, value), expected, "{kind:?} {value}"); + } + for order in [Little, Big] { + let read = |value: Value| { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + set(&mut arw.plain, DISTORTION.setting.0, value); + sony(&decode(&arw.build())).distortion.setting.clone() + }; + assert_eq!(read(Value::Short(vec![17])).value, Some(SonySetting { value: 17, meaning: ForcedAuto })); + assert_eq!(read(Value::Short(vec![9])).value, Some(SonySetting { value: 9, meaning: Unknown(9) })); + // the documented editor type (SSHORT) is accepted when not negative + assert_eq!(read(Value::SShort(vec![1])).value, Some(SonySetting { value: 1, meaning: Auto })); + for (value, error) in [ + (Value::SShort(vec![-1]), NegativeSetting(-1)), + (Value::SShort(vec![i16::MIN]), NegativeSetting(i16::MIN)), + (Value::Long(vec![1]), FieldType(4)), + (Value::Ascii("1".into()), FieldType(2)), + (Value::Short(vec![1, 1]), Count(2)), + (Value::Short(vec![0; 40]), Count(40)), + ] { + let f = read(value.clone()); + assert_eq!(prov(&f), (false, None, Rejected(error), Absent, None), "{value:?} {order:?}"); + } + } + } + + #[test] + fn malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy() { + let d = DISTORTION.table.0; + let cases = [ + (d, sshort(16, &[]), Count(1)), + (d, Value::SShort(vec![16, 0]), Count(2)), + (d, sshort(16, &TELE[..15]), Count(16)), + (d, Value::SShort(std::iter::once(16).chain(TELE).chain([0]).collect()), Count(18)), + (d, Value::SShort(vec![15; 17]), CountPrefix(15)), + (d, sshort(-16, &TELE), CountPrefix(-16)), + (d, sshort(0, &TELE), CountPrefix(0)), + (d, Value::Short(vec![16; 17]), FieldType(3)), + (d, Value::Long(vec![16; 17]), FieldType(4)), + (d, Value::Double(vec![f64::NAN; 17]), FieldType(12)), + (d, Value::Undefined(vec![16; 34]), FieldType(7)), + (SHADING.table.0, sshort(32, &[0; 32]), Count(33)), + (SHADING.table.0, Value::Short(std::iter::once(16).chain([0; 16]).collect()), FieldType(3)), + (CA.table.0, sshort(16, &ca()[..16]), Count(17)), + (CA.table.0, sshort(16, &ca()), CountPrefix(16)), + (CA.table.0, sshort(33, &ca()), CountPrefix(33)), + ]; + for order in [Little, Big] { + for (tag, value, error) in &cases { + let mut arw = Arw::new("ILCE-7RM4A", order); + set(&mut arw.plain, *tag, value.clone()); + let img = decode(&arw.build()); + let s = sony(&img); + let field = if *tag == SHADING.table.0 { + prov(&s.shading.table) + } else if *tag == CA.table.0 { + prov(&s.chromatic_aberration.table) + } else { + prov(&s.distortion.table) + }; + // the SR2SubIFD's good copy does not hide the corruption + assert_eq!(field, (false, None, Rejected(*error), Valid, None), "{tag:#x} {value:?} {order:?}"); + // the validated distortion needs its own raw-IFD table; a bad unrelated table doesn't remove it + assert_eq!(img.opcodes.list3.len(), usize::from(*tag != d), "{tag:#x} {value:?}"); + assert_eq!(s.distortion.status == SonyLensStatus::NoTable, *tag == d); + } + } + } + + #[test] + fn counts_and_offsets_beyond_the_data_are_reported_without_reading_them() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let e = entry_at(&good, order, DISTORTION.table.0); + let len = good.len() as u32; + for (field, value, error) in [ + // counts: the TIFF reader drops an entry whose values would pass the end of the file + (4, 0x7fff_ffff, OutsideData), + (4, u32::MAX, OutsideData), + (4, 0, Count(0)), + (4, 16, Count(16)), + // value offsets past the end, straddling it, at it + (8, u32::MAX, OutsideData), + (8, len - 10, OutsideData), + (8, len, OutsideData), + ] { + let mut file = good.clone(); + file[e + field..e + field + 4].copy_from_slice(&word(order, value)); + let img = decode(&file); + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(error), Valid, None), "{field} {value} {order:?}"); + assert!(img.opcodes.list3.is_empty()); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + assert_eq!(s.shading.table.value, Some(SHADING_T)); + } + } + } + + #[test] + fn a_table_past_the_tiff_readers_entry_limit_is_not_applied() { + // The TIFF reader that decodes the image reads 8,192 entries of a directory; with 8,200 filler entries ahead + // of them the image never sees the lens fields, so they must not become a correction (main applies none). + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.size, arw.sony_crop, arw.default_crop, arw.sr2) = ((30, 20), None, None, None); + let reference = decode(&arw.build()); + assert_eq!(reference.opcodes.list3.len(), 1, "control: the same file without filler is corrected"); + arw.plain = (0..8200u16).map(|i| (0x5000 + i, Value::Byte(vec![0]))).chain(plain(&TELE, 0)).collect(); + let img = decode(&arw.build()); + assert!(img.opcodes.list3.is_empty(), "{order:?}: a table the TIFF reader never read was applied"); + // the directory scan still finds and reports it + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(PastReaderLimits), Absent, None)); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + } + } + + #[test] + fn a_repeated_tag_keeps_the_copy_the_tiff_reader_keeps() { + let crop = Rect::new(2, 2, 30, 20); + let warp = |table: &[i16; 16]| -> Vec { distortion("ILCE-7RM4A", table, Rect::new(0, 0, 32, 24), crop).into_iter().collect() }; + for order in [Little, Big] { + // a second distortion table (WIDE) right after the first (TELE), under a placeholder tag renamed to 0x7037 + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.push((0x7040, sshort(16, &WIDE))); + let good = arw.build(); + let mut tag = Vec::new(); + order.put_u16(&mut tag, DISTORTION.table.0); + let mut twice = good.clone(); + let e = entry_at(&twice, order, 0x7040); + twice[e..e + 2].copy_from_slice(&tag); + // both valid: the first one, as the TIFF reader keeps it and main applies it + let img = decode(&twice); + assert_eq!(img.opcodes.list3, warp(&TELE), "{order:?}: both copies valid"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + // reported as a diagnostic, which changes nothing else + let f = &sony(&img).distortion.table; + assert_eq!((f.raw_ifd_repeated, f.sr2_repeated, f.raw_ifd, f.copies_agree), (true, false, Valid, Some(true))); + assert_eq!(sony(&img).distortion.status, SonyLensStatus::Applied); + assert!(!sony(&decode(&good)).distortion.table.raw_ifd_repeated); + // the first one's values outside the file: the TIFF reader drops it and keeps the second + let mut dropped = twice.clone(); + let first = entry_at(&dropped, order, DISTORTION.table.0); + dropped[first + 8..first + 12].copy_from_slice(&word(order, u32::MAX)); + let img = decode(&dropped); + assert_eq!(img.opcodes.list3, warp(&WIDE), "{order:?}: first copy unreadable"); + assert_eq!(sony(&img).distortion.table.value, Some(WIDE)); + assert!(sony(&img).distortion.table.raw_ifd_repeated); + // the first one malformed (the setting's SHORT entry renamed): the TIFF reader keeps it, nothing applies + let mut short = good.clone(); + let e = entry_at(&short, order, DISTORTION.setting.0); + short[e..e + 2].copy_from_slice(&tag); + let img = decode(&short); + assert!(img.opcodes.list3.is_empty(), "{order:?}: first copy malformed"); + assert_eq!(sony(&img).distortion.table.raw_ifd, Rejected(FieldType(SHORT))); + } + } + + #[test] + fn sr2_copies_fill_in_only_missing_raw_ifd_fields_and_are_never_applied() { + let (raw, sr2) = (Some(SonySource::RawIfd), Some(SonySource::Sr2SubIfd)); + for order in [Little, Big] { + // nothing in the raw IFD: every field from the SR2SubIFD, decoded, not applied even on the validated + // model (only the raw IFD's table was validated) + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(provenance(s), [(true, sr2, Absent, Valid, None); 6]); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(SHADING_T), Some(ca_curves()), Some(TELE)) + ); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + assert_eq!(s.distortion.status, SonyLensStatus::NotValidatedMode); + assert!(img.opcodes.list3.is_empty()); + // only the distortion table in the raw IFD: that one is used and applied, the rest comes from the SR2SubIFD + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.retain(|(tag, _)| *tag == DISTORTION.table.0); + let img = decode(&arw.build()); + let s = sony(&img); + let p = provenance(s); + assert_eq!(p[5], (true, raw, Valid, Valid, Some(true))); + assert!(p[..5].iter().all(|f| *f == (true, sr2, Absent, Valid, None)), "{p:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::Applied, 1)); + // the raw IFD's tag numbers inside the SR2SubIFD are not its tables + let mut arw = Arw::new("ILCE-7CR", order); + arw.plain.clear(); + let plain_tags = [SHADING.setting.0, SHADING.table.0, CA.setting.0, CA.table.0, DISTORTION.setting.0, DISTORTION.table.0]; + arw.sr2 = Some(encrypted(&TELE, 0).into_iter().zip(plain_tags).map(|((_, kind, v), tag)| (tag, kind, v)).collect()); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + } + } + + #[test] + fn disagreeing_copies_are_reported_and_the_raw_ifd_wins() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sr2 = Some(encrypted(&WIDE, 17)); + let img = decode(&arw.build()); + let s = sony(&img); + let raw = Some(SonySource::RawIfd); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.distortion.setting), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + assert_eq!(s.distortion.table.value, Some(TELE)); + assert_eq!(s.distortion.setting.value.map(|v| v.value), Some(0)); + // the validated conversion is unchanged: the raw IFD's table + let reference: Vec = distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 32, 24), Rect::new(2, 2, 30, 20)).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference); + } + } + + #[test] + fn unreadable_or_hostile_sr2_subifds_give_no_copies() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + // stored with another key: the block decrypts to noise, no copy is found, nothing falls back on it + let mut arw = Arw::new("ILCE-7CR", order); + arw.stored_key = [1, 2, 3, 4]; + let img = decode(&arw.build()); + let s = sony(&img); + assert!(provenance(s).iter().all(|&(value, source, plain, sr2, agree)| value + && source == raw + && plain == Valid + && sr2 != Valid + && agree.is_none())); + arw.plain.clear(); + let img = decode(&arw.build()); + assert!(img.lens_metadata.sony.as_ref().is_none_or(|s| provenance(s).iter().all(|f| !f.0 && f.3 != Valid))); + // no SR2SubIFD; DNGPrivateData pointing past the file or into the image + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + let s = sony(&decode(&arw.build())).clone(); + assert!(!s.sr2_read); + assert_eq!(provenance(&s), [(true, raw, Valid, Absent, None); 6]); + let good = Arw::new("ILCE-7CR", order).build(); + let at = Tiff::parse(&good).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + for pointer in [u32::MAX, good.len() as u32, 8] { + let mut file = good.clone(); + file[at..at + 4].copy_from_slice(&word(order, pointer)); + let img = decode(&file); + assert!(sony(&img).distortion.table.sr2 != Valid, "{pointer}"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + } + // hostile entries in a well-encrypted block (the distortion table is its sixth entry): another type, huge + // count, a value offset before the block or past it + let sixth = 2 + 12 * 5; + let put = move |at: usize, bytes: Vec| move |b: &mut [u8]| b[at..at + bytes.len()].copy_from_slice(&bytes); + let mut short = Vec::new(); + order.put_u16(&mut short, SHORT); + for (patch, error) in [ + (Box::new(put(sixth + 2, short.clone())) as Box, FieldType(SHORT)), + (Box::new(put(sixth + 4, word(order, u32::MAX))), Count(u64::from(u32::MAX))), + (Box::new(put(sixth + 4, word(order, 1))), Count(1)), + (Box::new(put(sixth + 8, word(order, 0))), OutsideData), + (Box::new(put(sixth + 8, word(order, u32::MAX))), OutsideData), + ] { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(patch); + let s = sony(&decode(&arw.build())).clone(); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Rejected(error), None), "{error:?}"); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + } + // an entry count past the end of the block: the scan stops where the block does + let mut count = Vec::new(); + order.put_u16(&mut count, u16::MAX); + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(Box::new(put(0, count))); + assert_eq!(provenance(sony(&decode(&arw.build()))), [(true, raw, Valid, Valid, Some(true)); 6]); + } + } + + #[test] + fn framing_and_coding_evidence_gate_the_validated_distortion() { + use SonyLensStatus::*; + let r = Rect::new; + for order in [Little, Big] { + let run = |arw: &Arw| { + let img = decode(&arw.build()); + let s = sony(&img).clone(); + (s, img) + }; + // Sony's crop at x = 0 and the default crop at x = 2 (the A7R IVA's 0 versus 32): the image keeps Sony's + // crop, the table is centred on and normalised to the default crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sony_crop = Some([0, 2, 30, 20]); + let (s, img) = run(&arw); + let g = &s.geometry; + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(r(0, 2, 30, 20)), Some(r(2, 2, 30, 20)), r(0, 2, 30, 20), r(2, 2, 30, 20)) + ); + assert_eq!((img.crop, s.distortion.status), (r(0, 2, 30, 20), Applied)); + let Some(Opcode::WarpRectilinear { center, .. }) = img.opcodes.list3.first() else { panic!("warp") }; + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + // agreeing crops (the A7CR's case), in a frame padded beyond them (lossless L): the storage is recorded, + // the reference stays the crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.size = (40, 28); + let (s, _) = run(&arw); + assert_eq!((s.geometry.storage_width, s.geometry.storage_height, s.geometry.reference), (40, 28, r(2, 2, 30, 20))); + assert_eq!(s.distortion.status, Applied); + // without a default crop the reference is the image crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.default_crop = None; + let (s, _) = run(&arw); + assert_eq!((s.geometry.default_crop, s.geometry.reference, s.distortion.status), (None, r(2, 2, 30, 20), Applied)); + // non-3:2 framing: an aspect crop in Sony's tags, in the default crop, or a 4:3 frame without crop tags + for (sony_crop, default_crop) in + [(Some([2, 2, 30, 16]), Some([2, 2, 30, 20])), (Some([2, 2, 30, 20]), Some([2, 2, 30, 16])), (None, None)] + { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (sony_crop, default_crop); + let (s, img) = run(&arw); + assert!(!s.geometry.native_3_2, "{sony_crop:?} {default_crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (NotValidatedMode, 0), "{sony_crop:?} {default_crop:?}"); + } + // neutral, out-of-range and folding tables in the validated scope; models outside it + for (model, table, status) in [ + ("ILCE-7RM4A", [0; 16], Neutral), + ("ILCE-7RM4A", [i16::MIN; 16], Rejected), + ("ILCE-7RM4A", [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Rejected), + ("ILCE-7RM4", TELE, NotValidatedModel), + ("ILCE-7CR", [0; 16], NotValidatedModel), + ("", TELE, NotValidatedModel), + ] { + let mut arw = Arw::new(model, order); + arw.plain = plain(&table, 1); + let (s, img) = run(&arw); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (status, 0), "{model} {table:?}"); + assert_eq!(s.distortion.table.value, Some(table)); + } + } + } + + #[test] + fn out_of_range_crop_tags_are_recorded_and_never_applied() { + for order in [Little, Big] { + // past the frame, enormous, empty, partly outside + for crop in [[99999, 0, 30, 20], [u32::MAX; 4], [2, 2, 0, 0], [30, 2, 30, 20]] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (Some(crop), Some(crop)); + let img = decode(&arw.build()); + let s = sony(&img); + let [x, y, w, h] = crop.map(|v| v as usize); + assert_eq!((s.geometry.sony_crop, s.geometry.default_crop), (Some(Rect::new(x, y, w, h)), Some(Rect::new(x, y, w, h))), "{crop:?}"); + assert!(!s.geometry.native_3_2, "{crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::NotValidatedMode, 0), "{crop:?}"); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + } + + #[test] + fn exif_orientation_does_not_change_the_record() { + // the tables and the warp are in sensor coordinates; the engine orients the warp (`engine::files::embedded_lens`) + let reference = decode(&Arw::new("ILCE-7RM4A", Little).build()); + for orientation in 1..=8u16 { + let mut arw = Arw::new("ILCE-7RM4A", Little); + arw.plain.push((t::ORIENTATION, Value::Short(vec![orientation]))); + let img = decode(&arw.build()); + assert_eq!(img.orientation, crate::Orientation::from_exif(orientation)); + assert_eq!((&img.lens_metadata, &img.opcodes), (&reference.lens_metadata, &reference.opcodes), "{orientation}"); + } + } + + #[test] + fn linear_ycbcr_files_are_decoded_but_not_corrected() { + // a downsized lossless (M/S) coding, as in arw.rs's test, from the validated model with 3:2 crop tags + for order in [Little, Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7RM4A".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::YCBCR])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(0x7010, Value::Short(vec![0; 4])); + raw.set(0x74c7, Value::Long(vec![0, 0])); + raw.set(0x74c8, Value::Long(vec![3, 2])); + for (tag, value) in plain(&TELE, 17) { + raw.set(tag, value); + } + raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![crate::ljpeg::tests::fixture_420()] }); + let img = decode(&TiffWriter::new(order, false).write(&[raw]).unwrap()); + let s = sony(&img); + assert!(s.geometry.linear_ycbcr && s.geometry.native_3_2); + assert_eq!((s.geometry.compression, s.distortion.status, img.opcodes.list3.len()), (7, SonyLensStatus::NotValidatedMode, 0)); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 17, meaning: SonySettingMeaning::ForcedAuto })); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + + #[test] + fn files_without_lens_fields_have_no_record() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + arw.sr2 = None; + let img = decode(&arw.build()); + assert_eq!((img.lens_metadata.clone(), img.opcodes.list3.len()), (LensMetadata::default(), 0)); + // an SR2SubIFD holding other tags only + arw.sr2 = Some(vec![(0x7310, SHORT, vec![512; 4])]); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + // one field anywhere is a record + arw.sr2 = Some(vec![(DISTORTION.setting.1, SHORT, vec![255])]); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 255, meaning: SonySettingMeaning::Unavailable })); + assert_eq!( + (s.distortion.status, s.shading.status, s.chromatic_aberration.status), + (SonyLensStatus::NoTable, SonyLensStatus::NoTable, SonyLensStatus::NoTable) + ); + } + } + + #[test] + fn corrupt_files_never_panic() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let tiff = Tiff::parse(&good).unwrap(); + let strip = tiff.ifds[0].image().unwrap().chunks(good.len() as u64)[0]; + let image = strip.offset as usize..(strip.offset + strip.len) as usize; + let check = |file: &[u8]| { + let header = super::super::arw::decode(file, Mode::Header); + let full = super::super::arw::decode(file, Mode::Full); + if let (Ok(h), Ok(f)) = (header, full) { + assert_eq!((h.lens_metadata, h.opcodes), (f.lens_metadata, f.opcodes)); + } + }; + for len in 0..good.len() { + check(&good[..len]); + } + for at in (0..good.len()).filter(|at| !image.contains(at)) { + for v in [0, 0xff, good[at] ^ 0x80, good[at] ^ 0x01] { + let mut file = good.clone(); + file[at] = v; + check(&file); + } + } + } + } #[test] fn matches_independent_sony_reference_geometry() { // On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted. - let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &table(&TELE), AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &TELE, AREA, AREA).unwrap() else { panic!("warp") }; assert_eq!(center, [0.5, 0.5]); let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638]; for i in 0..=100 { @@ -151,8 +1169,8 @@ mod tests { fn barrel_framing_and_offset_crop_keep_the_same_geometry() { let crop = Rect::new(32, 20, 9504, 6336); let active = Rect::new(0, 0, 9600, 6376); - let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") }; - let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &table(&WIDE), active, crop).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &WIDE, AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &WIDE, active, crop).unwrap() else { panic!("warp") }; assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle"); assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10); assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10); @@ -162,37 +1180,28 @@ mod tests { } } + /// The conversion's own refusals; missing and malformed layouts are refused by the decoder before it (see + /// `malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy`). #[test] - fn refuses_missing_unknown_malformed_or_unusable_tables() { - for bad in [ - Ifd::default(), - table(&[]), - table(&TELE[..15]), - table(&[0; 16]), - table(&[i16::MIN; 16]), - table(&[0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - ifd(Value::SShort(vec![15; 17])), - ifd(Value::Short(vec![16; 17])), - ifd(Value::Double(vec![f64::NAN; 17])), - ] { + fn refuses_unusable_tables() { + for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { assert!(distortion("ILCE-7RM4A", &bad, AREA, AREA).is_none()); } - assert!(distortion("ILCE-7RM4A", &table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none()); - assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none()); - assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 1, 1), AREA).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(0, 0, 9504, 5346)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(99999, 0, 10, 10)).is_none()); } + #[test] fn refuses_unvalidated_models_even_with_a_valid_table() { - let valid = table(&TELE); - assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some()); - for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] { - assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}"); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, AREA).is_some()); + for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR"] { + assert!(distortion(model, &TELE, AREA, AREA).is_none(), "{model}"); } } #[test] fn header_and_full_decode_preserve_correction_through_dng() { - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t}; for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] { for (height, expected) in [(20, expected), (16, 0)] { for order in [ByteOrder::Little, ByteOrder::Big] { @@ -211,7 +1220,7 @@ mod tests { raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); // An aspect crop must stay excluded even when DefaultCrop still describes 3:2. raw.set(0x74c8, Value::Long(vec![30, height])); - raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + raw.set(DISTORTION.table.0, Value::SShort(std::iter::once(16).chain(TELE).collect())); // Zero pixels have the same byte representation in both orders. raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 74d93534d..3fe173e09 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -249,8 +249,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/cr2/sraw.rs b/crates/raw/src/vendor/cr2/sraw.rs index 68b406238..6a37d627e 100644 --- a/crates/raw/src/vendor/cr2/sraw.rs +++ b/crates/raw/src/vendor/cr2/sraw.rs @@ -391,8 +391,10 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result // the camera's white balance is already applied, as in Sony's linear YCbCr wb_multipliers: Some([1.0; 3]), linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 7c93e3bac..3df33424d 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -253,8 +253,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: levels.wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; image.validate_for(mode)?; Ok(image) diff --git a/crates/raw/src/vendor/erf.rs b/crates/raw/src/vendor/erf.rs index fae120bcc..b9861ef38 100644 --- a/crates/raw/src/vendor/erf.rs +++ b/crates/raw/src/vendor/erf.rs @@ -123,6 +123,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), + pixel_aspect: 1.0, }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/kodak.rs b/crates/raw/src/vendor/kodak.rs index f7f82cff8..1e972873e 100644 --- a/crates/raw/src/vendor/kodak.rs +++ b/crates/raw/src/vendor/kodak.rs @@ -115,8 +115,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/mef.rs b/crates/raw/src/vendor/mef.rs index 244245818..207bc8871 100644 --- a/crates/raw/src/vendor/mef.rs +++ b/crates/raw/src/vendor/mef.rs @@ -121,6 +121,8 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { linearized: false, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), + pixel_aspect: 1.0, }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 271f9c71f..5076c6df9 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -175,6 +175,16 @@ fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) } +/// Pixel height over width from the raw IFD's resolution tags: the D1X and D1H write 600 x 300 per inch for a +/// 4028 x 1324 frame, rows twice as tall as the columns are wide. Only ratios from 1.5 to 4 count (a frame +/// squashed 3:1 or 2:1), the rest is rounding or a stray tag. +pub(crate) fn pixel_aspect_of(raw: &Ifd) -> f32 { + match (raw.f64(t::X_RESOLUTION), raw.f64(t::Y_RESOLUTION)) { + (Some(x), Some(y)) if x > 0.0 && y > 0.0 && (1.5..=4.0).contains(&(x / y)) => (x / y).round() as f32, + _ => 1.0, + } +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -268,9 +278,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), _ => default_crop(mn.as_ref(), active_w, h), }; + let pixel_aspect = pixel_aspect_of(raw); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); - metadata.height = Some(crop.height as u32); + metadata.height = Some(crate::stretched_height(crop.height, pixel_aspect) as u32); let img = RawImage { format: RawFormat::Nef, width: w, @@ -290,12 +301,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect, // Nikon's in-file distortion correction, about the optical centre (which follows the sensor shift) opcodes: OpcodeLists { list3: super::nef_lens::distortion(&model, raw, Rect::new(0, 0, active_w, h), crop).into_iter().collect(), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 31dd2d21b..92caeec26 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -334,8 +334,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index d8e84db99..9b7d92bc7 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -335,8 +335,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 7b148929d..42ed29ce8 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -212,8 +212,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 62a8f1130..7ebee5916 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -614,8 +614,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/sr2.rs b/crates/raw/src/vendor/sr2.rs index 212533add..10fc15074 100644 --- a/crates/raw/src/vendor/sr2.rs +++ b/crates/raw/src/vendor/sr2.rs @@ -45,7 +45,7 @@ //! of an accident 2⁻⁴⁰⁶⁴). For the DSC-R1's key `63 be 01 3e` the 11932-byte block becomes a well-formed 101-entry //! directory (black `0x7300`, white balance `0x7303`, crop `0x74c3`). -use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, tags as t}; +use lightcraft_tiff::{ByteOrder, EntrySpan, Ifd, ParseOptions, decode_value, directory_entries, entry_span, tags as t}; /// `SR2Private` tags. const SUBIFD_OFFSET: u16 = 0x7200; @@ -136,11 +136,8 @@ impl SubIfd { /// [`MAX_LEN`]. pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option { let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?; - let opts = ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; - let (private, _) = lightcraft_tiff::parse_ifd_at(bytes, u64::from(at), order, 0, false, &opts).ok()?; - let key: [u8; 4] = private.bytes(SUBIFD_KEY)?.try_into().ok()?; - let start = usize::try_from(private.u64(SUBIFD_OFFSET)?).ok()?; - let len = usize::try_from(private.u64(SUBIFD_LENGTH)?).ok()?; + let fields = private_fields(bytes, u64::from(at), order); + let (start, len, key) = (fields.offset?, fields.length?, fields.key?); if len > MAX_LEN { return None; } @@ -165,6 +162,13 @@ impl SubIfd { self.order } + /// The decrypted block (the directory from its entry count on) and the file offset it was stored at, which its + /// value offsets count from: for readers that check each entry's exact type and count themselves + /// (`arw_lens`). + pub(crate) fn block(&self) -> (&[u8], usize) { + (&self.plain, self.start) + } + /// The field type and value bytes of a SHORT or SSHORT entry; `None` when the tag is absent, has another type /// or its values lie outside the block. pub(crate) fn short_bytes(&self, tag: u16) -> Option<(u16, &[u8])> { @@ -193,9 +197,69 @@ impl SubIfd { } } +/// The `SR2Private` fields that locate the block. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct PrivateFields { + offset: Option, + length: Option, + key: Option<[u8; 4]>, +} + +/// The block's file offset, length and key from the `SR2Private` directory at `at`, exactly as the TIFF reader would +/// give them (`parse_ifd_at` with its default limits, then its `u64` and `bytes` lookups), without decoding the +/// directory. The selection uses the reader's own rules: its directory layout and entry limit +/// ([`directory_entries`]), which entries it can read ([`entry_span`]) and its value budget; each field is its first +/// readable occurrence. Of that entry only the first value is decoded (the key's first five bytes, which settle +/// whether it is four bytes), so a hostile directory can't make this allocate memory sized by its other entries, +/// which could each alias the whole file, before the block's own [`MAX_LEN`] is checked. +fn private_fields(bytes: &[u8], at: u64, order: ByteOrder) -> PrivateFields { + let opts = ParseOptions::default(); + let Ok(dir) = directory_entries(bytes, at, order, false, opts.max_entries) else { return PrivateFields::default() }; + let mut budget = opts.budget_for(bytes.len()); + let (mut offset, mut length, mut key) = (None, None, None); + for p in dir.positions() { + let Some(span) = entry_span(bytes, p, order, false, 0) else { continue }; + if span.len > budget { + continue; + } + budget -= span.len; + match span.tag { + SUBIFD_OFFSET if offset.is_none() => offset = Some(first_number(bytes, &span, order)), + SUBIFD_LENGTH if length.is_none() => length = Some(first_number(bytes, &span, order)), + SUBIFD_KEY if key.is_none() => key = Some(four_bytes(bytes, &span, order)), + _ => {} + } + } + PrivateFields { offset: offset.flatten(), length: length.flatten(), key: key.flatten() } +} + +/// The entry's first value as the reader's `u64` lookup reads it (an unsigned or non-negative integer), as a `usize`. +fn first_number(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option { + if span.count == 0 { + return None; + } + let start = usize::try_from(span.start).ok()?; + let first = bytes.get(start..start.checked_add(span.field_type.size())?)?; + usize::try_from(decode_value(order, span.field_type, first).get_u64(0)?).ok() +} + +/// The entry's bytes as the reader's `bytes` lookup gives them, when they are exactly four. Decoding the first five +/// value bytes settles it: BYTE and UNDEFINED give all their bytes, ASCII its text before the first NUL, which +/// decoding never makes shorter. +fn four_bytes(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<[u8; 4]> { + let start = usize::try_from(span.start).ok()?; + let head = bytes.get(start..start.checked_add(usize::try_from(span.len).ok()?.min(5))?)?; + <[u8; 4]>::try_from(decode_value(order, span.field_type, head).as_bytes()?).ok() +} + #[cfg(test)] pub(crate) mod tests { use super::*; + /// TIFF field types. + const BYTE: u16 = 1; + const LONG: u16 = 4; + const UNDEFINED: u16 = 7; + const IFD: u16 = 13; use lightcraft_tiff::ByteOrder::{Big, Little}; /// An `SR2SubIFD` block for file offset `start`, encrypted: SHORT/SSHORT entries `(tag, type, values)`, values @@ -345,6 +409,200 @@ pub(crate) mod tests { } } + /// An `SR2Private` directory of `entries` `(tag, type, count, value field)` at file offset 0. + fn private(entries: &[(u16, u16, u32, [u8; 4])], order: ByteOrder) -> Vec { + let mut out = Vec::new(); + order.put_u16(&mut out, entries.len() as u16); + for &(tag, kind, count, value) in entries { + order.put_u16(&mut out, tag); + order.put_u16(&mut out, kind); + order.put_u32(&mut out, count); + out.extend(value); + } + order.put_u32(&mut out, 0); + out + } + + /// `(offset, length, key)` as one of the readers selects them. + type Selected = (Option, Option, Option<[u8; 4]>); + + /// What `SubIfd::read` on main selects: the TIFF reader's parse of the directory with main's options, then the + /// directory's `u64` and `bytes` lookups. + fn reader_selects(bytes: &[u8], at: usize, order: ByteOrder) -> Selected { + let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + let Ok((private, _)) = lightcraft_tiff::parse_ifd_at(bytes, at as u64, order, 0, false, &opts) else { return (None, None, None) }; + let n = |tag: u16| private.u64(tag).and_then(|v| usize::try_from(v).ok()); + (n(SUBIFD_OFFSET), n(SUBIFD_LENGTH), private.bytes(SUBIFD_KEY).and_then(|b| <[u8; 4]>::try_from(b).ok())) + } + + /// Synthetic `SR2Private` directories at offset 0, with what each tests: filler entries ahead of the fields, and + /// unreadable, unusual or valid first occurrences of each field ahead of a valid second one. + fn differential_cases(order: ByteOrder) -> Vec<(String, Vec)> { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let h = |a: u16, b: u16| { + let mut out = Vec::new(); + order.put_u16(&mut out, a); + order.put_u16(&mut out, b); + [out[0], out[1], out[2], out[3]] + }; + let valid = [(SUBIFD_OFFSET, LONG, 1, w(4096)), (SUBIFD_LENGTH, LONG, 1, w(64)), (SUBIFD_KEY, UNDEFINED, 4, KEY)]; + let mut cases = Vec::new(); + for fillers in [0usize, 1, 255, 256, 257, 8189, 8190, 8191, 8192, 8193] { + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + entries.extend(valid); + cases.push((format!("{fillers} fillers"), private(&entries, order))); + } + // a first occurrence ahead of the valid one; pointer values point into a 32-byte tail after the directory + let tail_at = 2 + 12 * 4 + 4; + let mut tail: Vec = (1..=32).collect(); + tail[4..8].copy_from_slice(b"\x11\x22\x33\x44"); + tail[8] = 0; + let numeric: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type 0", 0, 1, w(8)), + ("unknown type 14", 14, 1, w(8)), + ("unknown type 0xffff", 0xffff, 1, w(8)), + ("LONG[2] past the file", LONG, 2, w(u32::MAX)), + ("LONG[2] straddling the end", LONG, 2, w(tail_at as u32 + 28)), + ("LONG[u32::MAX]", LONG, u32::MAX, w(tail_at as u32)), + ("DOUBLE[u32::MAX]", 12, u32::MAX, w(tail_at as u32)), + ("LONG[0]", LONG, 0, w(0)), + ("LONG[2] in the file", LONG, 2, w(tail_at as u32)), + ("SHORT[1]", SHORT, 1, h(9, 0)), + ("SHORT[2] inline", SHORT, 2, h(9, 10)), + ("BYTE[3]", BYTE, 3, [5, 6, 7, 0]), + ("SSHORT -1", SSHORT, 1, h(0xffff, 0)), + ("SSHORT 5", SSHORT, 1, h(5, 0)), + ("SLONG -1", 9, 1, w(u32::MAX)), + ("LONG8 at a pointer", 16, 1, w(tail_at as u32)), + ("RATIONAL", 5, 1, w(tail_at as u32)), + ("ASCII", 2, 4, *b"abc\0"), + ("IFD", IFD, 1, w(4096)), + ]; + for (tag, other) in [(SUBIFD_OFFSET, [valid[1], valid[2]]), (SUBIFD_LENGTH, [valid[0], valid[2]])] { + for (what, kind, count, field) in &numeric { + let valid_one = if tag == SUBIFD_OFFSET { valid[0] } else { valid[1] }; + let mut d = private(&[(tag, *kind, *count, *field), valid_one, other[0], other[1]], order); + d.extend(&tail); + cases.push((format!("{tag:#x}: first {what}"), d)); + } + } + let keys: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type", 0, 4, KEY), + ("UNDEFINED[8] past the file", UNDEFINED, 8, w(u32::MAX)), + ("UNDEFINED[5] in the file", UNDEFINED, 5, w(tail_at as u32)), + ("UNDEFINED[3]", UNDEFINED, 3, KEY), + ("UNDEFINED[0]", UNDEFINED, 0, KEY), + ("BYTE[4]", BYTE, 4, [1, 2, 3, 4]), + ("SBYTE[4]", 6, 4, KEY), + ("ASCII[4]", 2, 4, KEY), + ("ASCII[4] with NUL", 2, 4, [0x41, 0x42, 0, 0x43]), + ("ASCII[4] invalid UTF-8", 2, 4, [0xff, 0x41, 0, 0]), + ("ASCII[4] two invalid bytes", 2, 4, [0xc3, 0x28, 0x41, 0]), + ("ASCII[9] at a pointer, NUL at 4", 2, 9, w(tail_at as u32 + 4)), + ("ASCII[8] at a pointer, no NUL", 2, 8, w(tail_at as u32 + 12)), + ("LONG[1]", LONG, 1, KEY), + ]; + for (what, kind, count, field) in &keys { + let mut d = private(&[(SUBIFD_KEY, *kind, *count, *field), valid[0], valid[1], valid[2]], order); + d.extend(&tail); + cases.push((format!("key: first {what}"), d)); + } + // the reader's value budget (8 × the data's length + 1 MiB), used up by entries aliasing the data + for aliases in [23u32, 24] { + let mut entries = vec![(0x7300, UNDEFINED, 1 << 16, w(0)); aliases as usize]; + entries.extend(valid); + let mut d = private(&entries, order); + d.resize(1 << 16, 0); + cases.push((format!("{aliases} entries using the value budget"), d)); + } + // no entries; more entries declared than the data holds + cases.push(("empty directory".into(), private(&[], order))); + let mut long = private(&valid, order); + long[..2].copy_from_slice(&if order == Little { 10u16.to_le_bytes() } else { 10u16.to_be_bytes() }); + long.truncate(2 + 12 * 3); + cases.push(("10 entries declared, 3 present".into(), long)); + cases + } + + #[test] + fn private_fields_match_the_tiff_readers_selection() { + let mut differ = Vec::new(); + for order in [Little, Big] { + for (case, d) in differential_cases(order) { + let f = private_fields(&d, 0, order); + let (ours, reader) = ((f.offset, f.length, f.key), reader_selects(&d, 0, order)); + if ours != reader { + differ.push(format!("{order:?} {case}: {ours:?}, reader {reader:?}")); + } + } + assert_eq!(private_fields(&[], 0, order), PrivateFields::default()); + assert_eq!(private_fields(&private(&[], order), u64::MAX, order), PrivateFields::default()); + } + assert!(differ.is_empty(), "{} cases differ from the TIFF reader:\n{}", differ.len(), differ.join("\n")); + // what the reader (and so this) gives in the cases the review named: the limit is 8,192 entries, a later + // copy stands in for an unreadable first one, and a used-up value budget hides the fields + let found = |d: &[u8]| private_fields(d, 0, Little) == PrivateFields { offset: Some(4096), length: Some(64), key: Some(KEY) }; + let cases = differential_cases(Little); + let case = |name: &str| cases.iter().find(|(c, _)| c == name).map(|(_, d)| d.clone()).unwrap(); + for name in [ + "0 fillers", + "256 fillers", + "8189 fillers", + "0x7200: first LONG[2] past the file", + "0x7201: first unknown type 14", + "key: first UNDEFINED[8] past the file", + "23 entries using the value budget", + ] { + assert!(found(&case(name)), "{name}"); + } + for name in ["8190 fillers", "8192 fillers", "24 entries using the value budget", "0x7200: first ASCII", "key: first UNDEFINED[3]"] { + assert!(!found(&case(name)), "{name}"); + } + } + + /// The case that broke on review: valid entries ahead of the three fields, and an unreadable first offset ahead of + /// a valid one. Main's reader finds the block; so must this one (else the black level, white balance and crop it + /// holds are lost). + #[test] + fn the_block_is_found_behind_filler_and_unreadable_entries() { + for order in [Little, Big] { + for (fillers, unreadable_first) in [(256usize, false), (300, false), (0, true), (256, true)] { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let n = fillers + 3 + usize::from(unreadable_first); + let start = 2 + 12 * n + 4; + let block = block(start, &[(0x7310, SHORT, &[800; 4])], order); + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + if unreadable_first { + entries.push((SUBIFD_OFFSET, LONG, 2, w(u32::MAX))); + } + entries.extend([ + (SUBIFD_OFFSET, LONG, 1, w(start as u32)), + (SUBIFD_LENGTH, LONG, 1, w(block.len() as u32)), + (SUBIFD_KEY, UNDEFINED, 4, KEY), + ]); + let mut file = private(&entries, order); + assert_eq!(file.len(), start); + file.extend(&block); + let mut pointer = Vec::new(); + order.put_u32(&mut pointer, 0); + let ifd0 = Ifd { + entries: vec![lightcraft_tiff::Entry { tag: t::DNG_PRIVATE_DATA, value: lightcraft_tiff::Value::Byte(pointer), offset: 0 }], + ..Default::default() + }; + let black = SubIfd::read(&file, &ifd0, order).and_then(|s| s.shorts(0x7310)); + assert_eq!(black, Some(vec![800.0; 4]), "{order:?}: {fillers} fillers, unreadable first offset {unreadable_first}"); + } + } + } + #[test] fn hostile_directories_give_none() { let start = 4096; diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs index f64b451ff..b22faeec0 100644 --- a/crates/raw/src/vendor/srw.rs +++ b/crates/raw/src/vendor/srw.rs @@ -184,8 +184,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5d738b54d..31e62f7dc 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1,7 +1,8 @@ //! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with //! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent. //! -//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report +//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, generic CFA TIFF, +//! older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times //! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). @@ -25,7 +26,7 @@ const KNOWN_UNSUPPORTED: &[&str] = &[ fn corpus_raw_decodes() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut paths: Vec = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect(); @@ -41,7 +42,7 @@ fn corpus_raw_decodes() { let t0 = Instant::now(); let preview = embedded_preview(&bytes); let tp = t0.elapsed().as_secs_f64() * 1e3; - // DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG, + // DNG and generic CFA TIFF previews can be absent or uncompressed RGB; other vendor raws embed a JPEG, // except the Panasonic `.RAW` files of 2005–2007, Canon's explicit HEVC preview tracks and Mamiya MEF. if let Some(p) = &preview { let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]); @@ -53,7 +54,7 @@ fn corpus_raw_decodes() { .is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some())); // the Mamiya ZD's previews are uncompressed RGB sub-IFDs, not JPEGs assert!( - fmt == RawFormat::Dng || fmt == RawFormat::Mef || name.starts_with("raw-panasonic-") || hevc_preview, + matches!(fmt, RawFormat::Dng | RawFormat::CfaTiff | RawFormat::Mef) || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview" ); } @@ -163,7 +164,7 @@ fn corpus_cr2_cfa_patterns() { let mut seen = 0; for (name, want) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -193,7 +194,7 @@ fn corpus_cr2_as_shot_white_balance() { "cr2-canon-80d.cr2", ] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB")); @@ -210,7 +211,7 @@ fn corpus_nef_compressed_matches_uncompressed() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d5100-lossless.nef")), std::fs::read(dir.join("nef-nikon-d5100-uncompressed.nef"))) else { - eprintln!("skip: D5100 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D5100 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -235,7 +236,7 @@ fn corpus_nef_compressed_matches_uncompressed() { fn corpus_nef_high_efficiency_is_named() { let dir = corpus_root().join("raw"); let (Ok(he), Ok(lossless)) = (std::fs::read(dir.join("nef-nikon-zf-he.nef")), std::fs::read(dir.join("nef-nikon-zf-lossless.nef"))) else { - eprintln!("skip: Z f samples absent"); + lightcraft_testkit::skip!(Corpus, "Z f samples absent"); return; }; for result in [decode(&he).map(|_| ()), probe_info(&he).map(|_| ())] { @@ -252,7 +253,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef"))) else { - eprintln!("skip: D7500 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D7500 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -282,7 +283,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { fn corpus_dngs_carry_profile_looks() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut seen = 0; @@ -331,7 +332,7 @@ fn corpus_dngs_carry_profile_looks() { fn corpus_proraw_sky_matte() { let path = corpus_root().join("raw/dng-apple-iphone12pro-proraw.dng"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let t0 = Instant::now(); @@ -369,7 +370,7 @@ fn corpus_sony_pre2017_colour_metadata() { let mut seen = 0; for (name, black, [r, b], (cw, ch)) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -401,7 +402,7 @@ fn corpus_sony_a7cr_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -468,7 +469,7 @@ fn corpus_sony_a7cm2_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -513,7 +514,7 @@ fn corpus_sony_black_levels_sit_at_the_data_floor() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -571,7 +572,7 @@ fn corpus_sony_sr2_white_balance() { let mut seen = 0; for (name, [r, b]) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -589,7 +590,7 @@ fn corpus_sony_sr2_white_balance() { fn corpus_sony_a900_packed12() { let name = "arw-sony-a900-packed12.arw"; let Ok(bytes) = std::fs::read(corpus_root().join("raw").join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); return; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -611,7 +612,7 @@ fn corpus_sony_16x9_crops() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -812,7 +813,7 @@ fn corpus_panasonic_encodings() { let mut seen = 0; for (name, bits, black, (cw, ch), cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -862,7 +863,7 @@ fn corpus_orf_cfa_patterns() { let mut seen = 0; for (name, cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -897,7 +898,7 @@ fn corpus_samsung_srw() { let mut seen = 0; for (name, bits, active, crop, cfa, whole) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -1026,7 +1027,7 @@ fn corpus_sony_arw6_samples() { fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut checked = 0; @@ -1089,7 +1090,7 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { ] { let path = root.join(name); if !path.exists() { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } let bytes = std::fs::read(&path).unwrap(); @@ -1138,6 +1139,229 @@ fn nikon_in_file_distortion_is_limited_to_validated_bodies() { eprintln!("Nikon distortion boundary: {checked} corpus files checked"); } +/// The Sony samples of the corpus manifest (`RAW_SAMPLES` in `xtask/src/main.rs`) with their pinned SHA-256. +const SONY_SAMPLES: &[(&str, &str)] = &[ + ("arw-sony-a7rm4a-compressed.arw", "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67"), + ("arw-sony-a9m2-compressed.arw", "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97"), + ("arw-sony-a7m3-compressed.arw", "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f"), + ("arw-sony-a7m3-uncompressed.arw", "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd"), + ("arw-sony-a7m4-14bit.arw", "639a6d4db881f1359e3ea7e1137b314e59417d24e6d4d862b7202111a2c823b2"), + ("arw-sony-a7m4-lossless-l.arw", "851b43c2116c4139104a5036f83ac3b6a148789b2142214dd7192c13972b25b6"), + ("arw-sony-a7m4-lossless-m.arw", "d453005714327addd75bcb99c1c6223173dc92f2a59542d82e6761ef0e0e7571"), + ("arw-sony-a7m4-lossless-s.arw", "cbbd0930c7d8706dff84c68a2004454266e6fd0d8354f5f76a106b5d776e0223"), + ("arw-sony-a7cr-lossless-l.arw", "3e1642f3a1ae7c9f93228c5f09f27e362608e6a4ca1b3e5d8dd9eb8fae576997"), + ("arw-sony-a7cr-lossless-m.arw", "5bcba4acc52a5b902074581b4a2f5fb3c79ec56bd0fdc1746b9e2c0f9d3e9c88"), + ("arw-sony-a7cr-compressed.arw", "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5"), + ("arw-sony-a7cm2-lossless-l.arw", "342d893bd71fd050c273df181e3039d3f12d88947b0662de5de5325c84cc449b"), + ("arw-sony-a7cm2-compressed-m.arw", "002c86d38a16b08a2438780728b4fda8efcc745fa6c26231dad6af67b383b11e"), + ("arw-sony-a7rm4-14bit-compressed.arw", "e6dafe42643f69ab9d1fd00414b7a1f5104df354bb589201600ac934b29b5e4a"), + ("arw-sony-rx100m3.arw", "cb81392e3a8810231ce341fb45de3e4d1b9e2f87bc47f3c314d084041ccfd138"), + ("arw-sony-rx100.arw", "579a485b5126a25cbd55cbd5dadfa7d09cf021c99cc7d4869f9e56e3f759390b"), + ("arw-sony-a7rm2-12bit-uncompressed.arw", "71e0888396ef52c7e6a990b4e72ebb4d8413a1fc1c35bada449870539cef6e39"), + ("arw-sony-a500.arw", "1407fb596a391df67c15b90026a1a76702a2e7386dbdff2f0815b903ea49cead"), + ("arw-sony-a700.arw", "3159e28892bf0771d01525cd4b6190f9c15bbb19fa2fab6d2515ced0594e8f40"), + ("arw-sony-a33.arw", "1a59856394f10d4fadb40f5ab9c6d1c89f473f4dbcdefd216fbbbbf1ad8d21f9"), + ("arw-sony-a3500-5600k.arw", "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8"), + ("arw-sony-a7s-shade.arw", "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2"), + ("arw-sony-a7sm2-16x9.arw", "46044fb6a9c805f2b4915cd4970316b4d04b0566bcf5287afaad3ef648d7a3db"), + ("arw-sony-a580-16x9.arw", "5b0924d39151239dce19e92e08318f4f62a5a8ac276bd7180ec8463d2cfee709"), + ("arw-sony-a900-packed12.arw", "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50"), +]; + +/// SHA-256 of [`SONY_SAMPLES`] as `name sha256` lines sorted by name: anchors the pins independently of the +/// manifest, so editing both together (as a drift or a mistake) still fails `sony_pins_match_the_corpus_manifest`. +/// All 25 pins were checked against the downloaded files when it was set. +const SONY_PINS_SHA256: &str = "4cfc349194278146356b463c59ede500331c6ca5fc50aec4e92d7cd847b17b68"; + +/// A Sony corpus file's bytes, checked against its pinned SHA-256 before anything decodes them; `None` (with a +/// note) when the file is absent, unless `LIGHTCRAFT_CORPUS_REQUIRED=1` asks for every pinned file. A file that +/// differs from its pin fails the test. +fn pinned_sony(root: &Path, name: &str) -> Option> { + use sha2::{Digest, Sha256}; + let expected = SONY_SAMPLES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("{name}: no pinned SHA-256")).1; + let bytes = match std::fs::read(root.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + assert!(std::env::var_os("LIGHTCRAFT_CORPUS_REQUIRED").is_none_or(|v| v != "1"), "{name}: absent, and LIGHTCRAFT_CORPUS_REQUIRED=1"); + eprintln!("skip: {name} absent"); + return None; + } + Err(e) => panic!("{name}: {e}"), + }; + let actual: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(actual, expected, "{name}: corpus file differs from its pinned SHA-256"); + Some(bytes) +} + +#[test] +fn sony_pins_match_the_corpus_manifest() { + use sha2::{Digest, Sha256}; + let mut lines: Vec = SONY_SAMPLES.iter().map(|(name, sha)| format!("{name} {sha}\n")).collect(); + lines.sort(); + let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor"); + let manifest = include_str!("../../../xtask/src/main.rs"); + for (name, sha) in SONY_SAMPLES { + let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest")); + assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest"); + } +} + +#[test] +#[should_panic(expected = "differs from its pinned SHA-256")] +fn a_sony_fixture_that_differs_from_its_pin_fails() { + struct Gone(PathBuf); + impl Drop for Gone { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = Gone(std::env::temp_dir().join(format!("lightcraft-sony-pin-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0).unwrap(); + std::fs::write(dir.0.join("arw-sony-a7cr-compressed.arw"), b"not the pinned file").unwrap(); + pinned_sony(&dir.0, "arw-sony-a7cr-compressed.arw"); +} + +/// Sony's lens-correction records (`lightcraft_raw::lens_metadata`, `crates/raw/src/vendor/arw_lens.rs`) on the public +/// samples: shading, CA and distortion decoded from the raw IFD with their settings, equal to their `SR2SubIFD` copies, +/// the same from header and full decode, and nothing applied beyond the validated ILCE-7RM4A distortion. Expected +/// tables and settings: what ExifTool, run as a black-box tool, prints for these files. Every file is checked against +/// its pinned SHA-256 before it is decoded; absent files are skipped. +#[test] +fn sony_lens_records_of_the_public_samples() { + use lightcraft_raw::Rect; + use lightcraft_raw::lens_metadata::{SonyCopy, SonyLensStatus, SonySettingMeaning as M, SonySource}; + let root = corpus_root().join("raw"); + // ILCE-7CR (Sigma 24-70mm F2.8 DG DN Art at 49.5 mm): one scene in three codings, identical tables + let a7cr_shading = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + let a7cr_ca = [ + [-256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -128], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -128, -128, -256, -256], + ]; + let a7cr_distortion = [4, 1, 1, 3, 7, 13, 22, 35, 51, 72, 98, 132, 173, 223, 279, 348]; + // ILCE-7RM4A (FE 24-105mm F4 G OSS at 24 mm) + let a7rm4a_shading = [0, 32, 112, 256, 448, 688, 976, 1296, 1712, 2336, 3120, 4080, 5104, 6112, 7024, 7904]; + let a7rm4a_ca = [ + [1408, 1280, 1152, 1024, 1024, 1024, 1024, 1024, 896, 896, 896, 768, 768, 640, 512, 384], + [512, 512, 384, 384, 384, 384, 384, 512, 512, 512, 512, 640, 640, 640, 768, 640], + ]; + let a7rm4a_distortion = [14, 0, -29, -65, -113, -171, -239, -311, -392, -471, -555, -634, -713, -782, -843, -891]; + let a7cr_crop = Rect::new(32, 20, 9504, 6336); + // (file, settings, tables, distortion status, storage, compression, linear YCbCr, Sony crop, default crop) + let cases = [ + ( + "arw-sony-a7cr-compressed.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (9600, 6376), + 32767, + false, + a7cr_crop, + a7cr_crop, + ), + ( + "arw-sony-a7cr-lossless-l.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (9728, 6656), + 7, + false, + a7cr_crop, + a7cr_crop, + ), + ( + "arw-sony-a7cr-lossless-m.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (6656, 4608), + 7, + true, + Rect::new(20, 12, 6240, 4160), + Rect::new(20, 12, 6240, 4160), + ), + ( + "arw-sony-a7rm4a-compressed.arw", + [257, 1, 17], + (a7rm4a_shading, a7rm4a_ca, a7rm4a_distortion), + SonyLensStatus::Applied, + (9600, 6376), + 32767, + false, + Rect::new(0, 20, 9504, 6336), + Rect::new(32, 20, 9504, 6336), + ), + ]; + let mut checked = 0; + for (name, settings, (shading, ca, distortion), status, (w, h), compression, linear, sony_crop, default_crop) in cases { + let Some(bytes) = pinned_sony(&root, name) else { continue }; + let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}"); + let s = full.lens_metadata.sony.as_ref().unwrap_or_else(|| panic!("{name}: no Sony lens record")); + assert!(s.sr2_read, "{name}"); + let got = [s.shading.setting.value, s.chromatic_aberration.setting.value, s.distortion.setting.value].map(|v| v.map(|v| v.value)); + assert_eq!(got, settings.map(Some), "{name}: settings"); + let meanings = [s.shading.setting.value, s.chromatic_aberration.setting.value, s.distortion.setting.value].map(|v| v.map(|v| v.meaning)); + let forced = if settings[2] == 17 { M::ForcedAuto } else { M::Off }; + assert_eq!(meanings, [Some(M::Auto), Some(M::Auto), Some(forced)], "{name}: setting meanings"); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(shading), Some(ca), Some(distortion)), + "{name}" + ); + for (source, raw, sr2, agree) in [ + (s.shading.table.source, s.shading.table.raw_ifd, s.shading.table.sr2, s.shading.table.copies_agree), + ( + s.chromatic_aberration.table.source, + s.chromatic_aberration.table.raw_ifd, + s.chromatic_aberration.table.sr2, + s.chromatic_aberration.table.copies_agree, + ), + (s.distortion.table.source, s.distortion.table.raw_ifd, s.distortion.table.sr2, s.distortion.table.copies_agree), + (s.shading.setting.source, s.shading.setting.raw_ifd, s.shading.setting.sr2, s.shading.setting.copies_agree), + ( + s.chromatic_aberration.setting.source, + s.chromatic_aberration.setting.raw_ifd, + s.chromatic_aberration.setting.sr2, + s.chromatic_aberration.setting.copies_agree, + ), + (s.distortion.setting.source, s.distortion.setting.raw_ifd, s.distortion.setting.sr2, s.distortion.setting.copies_agree), + ] { + assert_eq!((source, raw, sr2, agree), (Some(SonySource::RawIfd), SonyCopy::Valid, SonyCopy::Valid, Some(true)), "{name}: provenance"); + } + assert_eq!( + (s.shading.status, s.chromatic_aberration.status, s.distortion.status), + (SonyLensStatus::NotValidatedComponent, SonyLensStatus::NotValidatedComponent, status), + "{name}" + ); + assert_eq!(full.opcodes.list3.len(), usize::from(status == SonyLensStatus::Applied), "{name}: only applied components are opcodes"); + let g = &s.geometry; + assert_eq!((g.storage_width, g.storage_height, g.compression, g.linear_ycbcr), (w, h, compression, linear), "{name}"); + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(sony_crop), Some(default_crop), sony_crop, default_crop), + "{name}" + ); + assert!(g.native_3_2, "{name}"); + eprintln!("{name}: shading, CA and distortion decoded; distortion {status:?}"); + checked += 1; + } + // every Sony corpus file: only the ILCE-7RM4A's distortion is ever applied, and an applied status is an opcode + for (name, _) in SONY_SAMPLES { + let Some(bytes) = pinned_sony(&root, name) else { continue }; + let info = probe_info(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let Some(s) = &info.lens_metadata.sony else { continue }; + let applied = s.distortion.status == SonyLensStatus::Applied; + assert_eq!(applied, info.metadata.model.as_deref() == Some("ILCE-7RM4A"), "{name}"); + assert_eq!(info.opcodes.list3.len(), usize::from(applied), "{name}"); + assert!(![s.shading.status, s.chromatic_aberration.status].contains(&SonyLensStatus::Applied), "{name}"); + checked += 1; + } + eprintln!("Sony lens records: {checked} checks on corpus files"); +} + /// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the /// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the /// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either. @@ -1146,7 +1370,7 @@ fn corpus_kodak_dcs520c() { let name = "2573_Kodak - DCS520C - 12bit (3_2).TIF"; let candidates = [corpus_root().join("raw").join("kodak-dcs520c.tif"), Path::new("P:/raw-pixls/Kodak/DCS520C").join(name)]; let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else { - eprintln!("skip: DCS520C sample absent"); + lightcraft_testkit::skip!(Corpus, "DCS520C sample absent"); return; }; assert_eq!(probe(&bytes), Some(RawFormat::KodakDcs)); diff --git a/crates/raw/tests/coverage_sweep_corpus.rs b/crates/raw/tests/coverage_sweep_corpus.rs new file mode 100644 index 000000000..78f384651 --- /dev/null +++ b/crates/raw/tests/coverage_sweep_corpus.rs @@ -0,0 +1,66 @@ +//! Sensor regressions for the public camera-coverage sweep. +//! +//! `docs/raw/coverage-sweep.tsv` records the file URLs, SHA-256 hashes, decoded dimensions, +//! FNV-1a sensor hashes and camera-image comparisons measured on main 25ef65c8. +//! Sensor hashes pin LightCraft's own output, not an independent decoder's output. +//! Hash input is every row-major sample in little-endian order, including masked borders. +//! Fetch the CC0 files with `cargo xtask corpus --download`. Missing files are reported. + +use lightcraft_raw::{RawData, decode, probe_info}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const RECORDS: &str = include_str!("../../../docs/raw/coverage-sweep.tsv"); + +#[test] +fn coverage_sweep_sensor_decodes_are_pinned() { + let corpus = + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + let mut checked = 0; + for line in RECORDS.lines().filter(|line| !line.starts_with('#')).skip(1) { + let columns: Vec<_> = line.split('\t').collect(); + assert_eq!(columns.len(), 14, "invalid coverage record: {line}"); + let name = columns[3]; + let path = corpus.join("raw").join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {} absent", path.display()); + continue; + } + Err(error) => panic!("{}: {error}", path.display()), + }; + assert_eq!(format!("{:x}", Sha256::digest(&bytes)), columns[5], "{name}: file SHA-256"); + let raw = decode(&bytes).unwrap_or_else(|error| panic!("{name}: {error}")); + raw.validate().unwrap(); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let dimensions: Vec = columns[6..9].iter().map(|value| value.parse().unwrap()).collect(); + assert_eq!([raw.width, raw.height, raw.cpp], dimensions.as_slice(), "{name}: sensor dimensions"); + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + let mut feed = |byte: u8| hash = (hash ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3); + match &raw.data { + RawData::U16(samples) => { + assert_eq!(columns[9], "u16", "{name}: sample type"); + for sample in samples { + for byte in sample.to_le_bytes() { + feed(byte); + } + } + } + RawData::F32(samples) => { + assert_eq!(columns[9], "f32", "{name}: sample type"); + for sample in samples { + assert!(sample.is_finite(), "{name}: non-finite sample"); + for byte in sample.to_le_bytes() { + feed(byte); + } + } + } + } + assert_eq!(format!("{hash:016x}"), columns[10], "{name}: sensor samples changed"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + checked += 1; + eprintln!("checked {name}: {}x{}, {hash:016x}", raw.width, raw.height); + } + eprintln!("camera-coverage sweep: {checked} files checked"); +} diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index c97db6103..8828192dd 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -22,7 +22,7 @@ fn check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize fn check_against(name: &str, hash: u64, sensor: (usize, usize), developed: Option<(usize, usize)>, reference: &str) { let path = corpus().join("raw").join(name); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -118,7 +118,7 @@ fn canon_m50_craw_crop_is_the_shot_aspect() { // which is (12, 12) inside the valid sensor area. let path = corpus().join("raw").join("cr3-canon-m50-craw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap(); diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index f95afef22..dddafd5d0 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -59,8 +59,10 @@ fn synthetic(w: usize, h: usize, cfa: Option, cpp: usize) -> RawImage { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), } } diff --git a/crates/raw/tests/lossy_dng.rs b/crates/raw/tests/lossy_dng.rs index dd34b5158..3b7480ff2 100644 --- a/crates/raw/tests/lossy_dng.rs +++ b/crates/raw/tests/lossy_dng.rs @@ -37,6 +37,8 @@ fn mosaic(w: usize, h: usize) -> RawImage { linearized: false, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, } } diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index b6cd9ab9f..323149fda 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -24,8 +24,10 @@ fn sample(comp: DngCompression) -> Vec { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), }; lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression: comp, ..Default::default() }).unwrap() } @@ -163,6 +165,8 @@ fn non_finite_black_levels_and_samples_develop() { linearized: false, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, }; for img in [raw.clone(), lightcraft_raw::decode(&lightcraft_raw::write_dng(&raw, &DngWriteOptions::default()).unwrap()).unwrap()] { let rgb = img.develop(Method::Ahd).unwrap(); diff --git a/crates/raw/tests/sony_lens_inventory.rs b/crates/raw/tests/sony_lens_inventory.rs new file mode 100644 index 000000000..3eda88418 --- /dev/null +++ b/crates/raw/tests/sony_lens_inventory.rs @@ -0,0 +1,351 @@ +//! Opt-in local check of Sony's lens-correction records ([`lightcraft_raw::lens_metadata`]); it does nothing unless +//! `LIGHTCRAFT_SONY_LENS_DIR` names a directory of ARW files, so CI never runs it. The files there (searched +//! recursively) are only read, from their headers, and one line per file is printed: settings, where each table came +//! from, whether its raw-IFD and `SR2SubIFD` copies agree, sample ranges and status. Header and full decode are +//! compared too when `LIGHTCRAFT_SONY_LENS_FULL=1` (slow on large files). +//! +//! With `LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory of those files (comma-separated, no quoting, a header +//! row, one row per file name), every file must have a row, every row a file, and these columns must match where +//! present: `split` (the file's folder name), `raw_size` (`WxH`), `compression`, `crop_origin` / `crop_size` (Sony's +//! crop tags, `x y` / `w h`), and for each of `shading`, `ca` and `distortion`: `_entries` (TIFF count), `_bytes`, +//! `_samples` (the leading sample count), `_min` / `_max` (over the samples), `_sr2_equal` (`True` / `False`), +//! `_setting_raw` and `_setting` (a label: `Off`, `Auto`, `Auto (ILCE-1)`, `Auto fixed by lens`, `No correction +//! params available`). A file that doesn't decode must still have a row, and fails the check when it has one. +//! +//! Any decode error fails the check except an unsupported variant (`RawError::Unsupported`, e.g. the packed ARW of +//! some older bodies): those are counted, and the count must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` (default 0). +//! +//! `LIGHTCRAFT_SONY_LENS_DIR=… LIGHTCRAFT_SONY_LENS_INVENTORY=….csv cargo test -p lightcraft-raw --release --test +//! sony_lens_inventory -- --nocapture` + +use lightcraft_raw::lens_metadata::{SonyCopy, SonyField, SonyLensCorrections, SonyLensStatus, SonySetting, SonySettingMeaning, SonySource}; +use lightcraft_raw::{RawError, RawInfo, decode, probe_info}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn arw_files(dir: &Path, depth: usize, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && depth < 8 { + arw_files(&path, depth + 1, out); + } else if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("arw")) { + out.push(path); + } + } +} + +/// One component's columns, from the decoded record. +struct Component { + setting: Option<(u16, SonySettingMeaning)>, + /// Samples when the table was decoded. + samples: Option>, + source: Option, + /// The raw-IFD and `SR2SubIFD` copies' states. + copies: (SonyCopy, SonyCopy), + agree: Option, + status: String, +} + +fn component(setting: &SonyField, table: &SonyField, samples: Option>, status: SonyLensStatus) -> Component { + Component { + setting: setting.value.map(|s| (s.value, s.meaning)), + samples, + source: table.source, + copies: (table.raw_ifd, table.sr2), + agree: table.copies_agree, + status: format!("{status:?}"), + } +} + +/// Shading, CA and distortion of a record, by CSV column prefix. +fn components(s: &SonyLensCorrections) -> [(&'static str, Component); 3] { + let (sh, ca, d) = (&s.shading, &s.chromatic_aberration, &s.distortion); + [ + ("shading", component(&sh.setting, &sh.table, sh.table.value.map(|v| v.to_vec()), sh.status)), + ("ca", component(&ca.setting, &ca.table, ca.table.value.map(|v| v.concat()), ca.status)), + ("distortion", component(&d.setting, &d.table, d.table.value.map(|v| v.to_vec()), d.status)), + ] +} + +fn label(meaning: SonySettingMeaning) -> &'static [&'static str] { + match meaning { + SonySettingMeaning::Off => &["Off"], + SonySettingMeaning::Auto => &["Auto", "Auto (ILCE-1)"], + SonySettingMeaning::ForcedAuto => &["Auto fixed by lens"], + SonySettingMeaning::Unavailable => &["No correction params available"], + SonySettingMeaning::Unknown(_) => &[], + } +} + +/// The columns this check derives from a file, by CSV column name. +fn columns(path: &Path, info: &RawInfo) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut put = |k: &str, v: String| { + out.insert(k.to_string(), v); + }; + if let Some(split) = path.parent().and_then(|p| p.file_name()) { + put("split", split.to_string_lossy().into_owned()); + } + let Some(s) = &info.lens_metadata.sony else { return out }; + let g = &s.geometry; + put("raw_size", format!("{}x{}", g.storage_width, g.storage_height)); + put("compression", g.compression.to_string()); + if let Some(c) = g.sony_crop { + put("crop_origin", format!("{} {}", c.x, c.y)); + put("crop_size", format!("{} {}", c.width, c.height)); + } + for (name, c) in components(s) { + if let Some(v) = &c.samples { + put(&format!("{name}_entries"), (v.len() + 1).to_string()); + put(&format!("{name}_bytes"), (2 * (v.len() + 1)).to_string()); + put(&format!("{name}_samples"), v.len().to_string()); + put(&format!("{name}_min"), v.iter().min().map_or(String::new(), i16::to_string)); + put(&format!("{name}_max"), v.iter().max().map_or(String::new(), i16::to_string)); + } + put(&format!("{name}_sr2_equal"), c.agree.map_or("unknown", |a| if a { "True" } else { "False" }).to_string()); + if let Some((value, _)) = c.setting { + put(&format!("{name}_setting_raw"), value.to_string()); + } + } + out +} + +fn summary(info: &RawInfo) -> String { + let Some(s) = &info.lens_metadata.sony else { return "no lens record".into() }; + let mut line = format!("sr2 {} |", if s.sr2_read { "read" } else { "absent" }); + for (name, c) in components(s) { + let setting = c.setting.map_or("-".into(), |(v, m)| format!("{v} {m:?}")); + let range = c + .samples + .as_ref() + .map_or("no table".into(), |v| format!("{} samples {}..{}", v.len(), v.iter().min().unwrap_or(&0), v.iter().max().unwrap_or(&0))); + line += + &format!(" {name}: {setting}, {range}, {:?} (copies {:?} / {:?}), agree {:?}, {} |", c.source, c.copies.0, c.copies.1, c.agree, c.status); + } + line + &format!(" crop {:?} reference {:?} 3:2 {}", s.geometry.image_crop, s.geometry.reference, s.geometry.native_3_2) +} + +/// `(header, rows by file name)` of a simple CSV file. +fn read_csv(path: &Path) -> (Vec, BTreeMap>) { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut lines = text.lines().filter(|l| !l.trim().is_empty()); + let header: Vec = lines.next().expect("CSV header").split(',').map(|s| s.trim().to_string()).collect(); + let file = header.iter().position(|h| h == "file").expect("a `file` column"); + let mut rows = BTreeMap::new(); + for line in lines { + assert!(!line.contains('"'), "quoted CSV fields are not supported: {line}"); + let row: Vec = line.split(',').map(|s| s.trim().to_string()).collect(); + assert_eq!(row.len(), header.len(), "CSV row with {} fields, header has {}: {line}", row.len(), header.len()); + let name = row[file].clone(); + assert!(rows.insert(name.clone(), row).is_none(), "{name} listed twice"); + } + (header, rows) +} + +#[test] +fn sony_lens_records_reproduce_a_local_inventory() { + let Some(dir) = std::env::var_os("LIGHTCRAFT_SONY_LENS_DIR").map(PathBuf::from) else { + eprintln!("skip: LIGHTCRAFT_SONY_LENS_DIR unset"); + return; + }; + let full = std::env::var_os("LIGHTCRAFT_SONY_LENS_FULL").is_some_and(|v| v == "1"); + let inventory = std::env::var_os("LIGHTCRAFT_SONY_LENS_INVENTORY").map(PathBuf::from); + let unsupported = std::env::var("LIGHTCRAFT_SONY_LENS_UNSUPPORTED").map_or(0, |v| v.parse().expect("LIGHTCRAFT_SONY_LENS_UNSUPPORTED: a count")); + let report = check(&dir, inventory.as_deref(), full, unsupported); + assert!(report.problems.is_empty(), "{} problems", report.problems.len()); +} + +/// What [`check`] found. +#[derive(Debug, Default)] +struct Report { + files: usize, + records: usize, + without_record: usize, + unsupported: usize, + problems: Vec, +} + +/// Check every ARW under `dir` (see the module docs). Any decode error is a problem except `Unsupported`, which +/// is counted and must come to exactly `expected_unsupported`; with an inventory, every file (decoded or not) must +/// have a row, and a file with a row must decode. +fn check(dir: &Path, inventory: Option<&Path>, full: bool, expected_unsupported: usize) -> Report { + let inventory = inventory.map(read_csv); + let mut files = Vec::new(); + arw_files(dir, 0, &mut files); + files.sort(); + let mut report = Report { files: files.len(), ..Default::default() }; + if files.is_empty() { + report.problems.push(format!("no ARW files under {}", dir.display())); + } + let (mut matched, mut mismatches, mut seen) = (0usize, Vec::new(), std::collections::BTreeSet::new()); + let compared: Vec = ["split", "raw_size", "compression", "crop_origin", "crop_size"] + .into_iter() + .map(String::from) + .chain( + ["shading", "ca", "distortion"] + .into_iter() + .flat_map(|k| ["entries", "bytes", "samples", "min", "max", "sr2_equal", "setting_raw"].into_iter().map(move |c| format!("{k}_{c}"))), + ) + .collect(); + for path in &files { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + seen.insert(name.clone()); + let info = match probe_info(&bytes) { + Ok(info) => info, + Err(e) => { + eprintln!("{name}: {e}"); + match (&inventory, &e) { + (Some((_, rows)), _) if rows.contains_key(&name) => mismatches.push(format!("{name}: does not decode: {e}")), + (Some(_), _) => mismatches.push(format!("{name}: does not decode ({e}) and is not in the inventory")), + (None, RawError::Unsupported(_)) => {} + (None, _) => mismatches.push(format!("{name}: does not decode: {e}")), + } + report.unsupported += usize::from(matches!(e, RawError::Unsupported(_))); + continue; + } + }; + if full { + match decode(&bytes) { + Ok(image) if image.info() == info => {} + Ok(_) => mismatches.push(format!("{name}: header and full decode differ")), + Err(e) => mismatches.push(format!("{name}: full decode fails: {e}")), + } + } + if info.lens_metadata.sony.is_some() { + report.records += 1; + } else { + report.without_record += 1; + } + eprintln!("{name}: {} {}", info.metadata.model.as_deref().unwrap_or("?"), summary(&info)); + let Some((header, rows)) = &inventory else { continue }; + let Some(row) = rows.get(&name) else { + mismatches.push(format!("{name}: not in the inventory")); + continue; + }; + let got = columns(path, &info); + let mut ok = true; + for (i, column) in header.iter().enumerate() { + let expected = &row[i]; + if let Some(setting) = column.strip_suffix("_setting") + && let Some(s) = &info.lens_metadata.sony + { + let meaning = components(s).into_iter().find(|(n, _)| *n == setting).and_then(|(_, c)| c.setting).map(|(_, m)| m); + if !meaning.is_some_and(|m| label(m).contains(&expected.as_str())) { + mismatches.push(format!("{name}: {column} {meaning:?}, inventory {expected}")); + ok = false; + } + continue; + } + if !compared.contains(column) { + continue; + } + let value = got.get(column).map(String::as_str).unwrap_or("(none)"); + if value != expected { + mismatches.push(format!("{name}: {column} {value}, inventory {expected}")); + ok = false; + } + } + matched += usize::from(ok); + } + if let Some((header, rows)) = &inventory { + for name in rows.keys().filter(|n| !seen.contains(*n)) { + mismatches.push(format!("{name}: in the inventory, no such file")); + } + let checked = header.iter().filter(|c| compared.contains(c) || c.ends_with("_setting")).count(); + assert!(checked > 0, "the inventory has none of the compared columns"); + eprintln!("{matched} of {} inventory rows reproduced from {} files ({checked} columns compared)", rows.len(), files.len()); + } + if report.unsupported != expected_unsupported { + mismatches.push(format!("{} files are unsupported variants, {expected_unsupported} expected", report.unsupported)); + } + eprintln!( + "{} files: {} with a lens record, {} decoded without one, {} unsupported ({expected_unsupported} expected)", + report.files, report.records, report.without_record, report.unsupported + ); + for m in &mismatches { + eprintln!("problem: {m}"); + } + report.problems.extend(mismatches); + report +} + +/// A directory under the system temporary directory, removed when dropped. +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lightcraft-sony-lens-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A small Sony CFA file with a distortion setting (a lens record), or, `unsupported`, the packed 12-bit ARW variant +/// the decoder refuses as unsupported. +fn synthetic_arw(unsupported: bool) -> Vec { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(0x7036, Value::Short(vec![0])); + if unsupported { + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0; 32 * 4 + 8]] }); + } else { + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0; 32 * 4 * 2]] }); + } + TiffWriter::default().write(&[raw]).unwrap() +} + +#[test] +fn files_that_fail_to_decode_fail_the_check() { + // a file that is no raw at all fails the check, whatever is expected + let dir = Scratch::new("decode-error"); + std::fs::write(dir.0.join("not-a-raw.arw"), b"not a raw file").unwrap(); + let report = check(&dir.0, None, false, 0); + assert!(!report.problems.is_empty(), "an ARW that does not decode passed the check"); + assert!(!check(&dir.0, None, false, 1).problems.is_empty(), "a decode error counted as an unsupported variant"); + // an unsupported variant is counted, and must be expected + let dir = Scratch::new("unsupported"); + std::fs::write(dir.0.join("good.arw"), synthetic_arw(false)).unwrap(); + std::fs::write(dir.0.join("packed.arw"), synthetic_arw(true)).unwrap(); + let report = check(&dir.0, None, false, 0); + assert_eq!((report.files, report.records, report.unsupported), (2, 1, 1)); + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!(check(&dir.0, None, true, 1).problems.is_empty()); + // with an inventory, a file that fails must still be listed: here it is not + let csv = dir.0.join("inventory.csv"); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\n").unwrap(); + // (the inventory itself sits in the directory, but only .arw files are checked) + let mut report = check(&dir.0, Some(&csv), false, 1); + report.problems.sort(); + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!(report.problems[0].contains("packed.arw") && report.problems[0].contains("not in the inventory"), "{:?}", report.problems); + // listed but failing, and listed and decoding + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\npacked.arw,0\n").unwrap(); + let report = check(&dir.0, Some(&csv), false, 1); + assert!(report.problems.len() == 1 && report.problems[0].contains("packed.arw: does not decode"), "{:?}", report.problems); + std::fs::remove_file(dir.0.join("packed.arw")).unwrap(); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\n").unwrap(); + assert!(check(&dir.0, Some(&csv), false, 0).problems.is_empty()); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,17\n").unwrap(); + assert!(!check(&dir.0, Some(&csv), false, 0).problems.is_empty(), "a wrong value passed"); +} diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/sparse_corpus.rs index 15e6398b2..a602dd148 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/sparse_corpus.rs @@ -58,7 +58,7 @@ fn partial_reads_describe_raws_as_whole_files_do() { .filter(|p| p.is_file() && !p.extension().is_some_and(|e| e == "part" || e == "txt" || e == "md")) .collect(); if paths.is_empty() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } paths.sort(); diff --git a/crates/raw/tests/sr2_heap.rs b/crates/raw/tests/sr2_heap.rs new file mode 100644 index 000000000..2b936d18c --- /dev/null +++ b/crates/raw/tests/sr2_heap.rs @@ -0,0 +1,79 @@ +//! Peak heap of reading Sony's `SR2Private` directory (`crates/raw/src/vendor/sr2.rs`), measured with dhat's testing +//! mode. Every ARW with `DNGPrivateData` has its `SR2SubIFD` located and decrypted, so the directory that points to +//! it must not cost memory sized by its other entries: a hostile file can give it fields that each alias most of the +//! file. Only the block's offset, length and key may be read. One test per binary (dhat's profiler is global). + +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +/// A 32 × 24 Sony CFA file of `size` bytes whose `SR2Private` directory holds the block's offset, length and key plus +/// `aliases` UNDEFINED fields that each cover the first `size` bytes of the file. Plain black level and white balance +/// are present, so nothing but the lens record needs the `SR2SubIFD`. +fn hostile_arw(size: usize, aliases: u16) -> Vec { + let o = ByteOrder::Little; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x7310, Value::Short(vec![512; 4])); + raw.set(0x7313, Value::Short(vec![2048, 1024, 1024, 1536])); + // a distortion setting, so the file has a lens record that says whether the SR2SubIFD was read + raw.set(0x7036, Value::Short(vec![0])); + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let mut file = TiffWriter::new(o, false).write(&[raw]).unwrap(); + let pointer = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + file.resize(size, 0); + let private = file.len(); + let entries = 3 + aliases; + let block_at = private + 2 + 12 * usize::from(entries) + 4; + let block_len = 64u32; + o.put_u16(&mut file, entries); + for (tag, kind, count, value) in [(0x7200u16, 4u16, 1u32, block_at as u32), (0x7201, 4, 1, block_len)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, kind); + o.put_u32(&mut file, count); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend([0x11, 0x22, 0x33, 0x44]); + for i in 0..aliases { + o.put_u16(&mut file, 0x7230 + i); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, size as u32); + o.put_u32(&mut file, 0); + } + o.put_u32(&mut file, 0); + file.resize(block_at + block_len as usize, 0); + let mut at = Vec::new(); + o.put_u32(&mut at, private as u32); + file[pointer..pointer + 4].copy_from_slice(&at); + file +} + +#[test] +fn sr2_private_fields_cost_no_memory_sized_by_the_file() { + // eight fields of 16 MiB each: 128 MiB if they were decoded, within the TIFF reader's default value budget + let file = hostile_arw(16 << 20, 8); + let _profiler = dhat::Profiler::builder().testing().build(); + let header = lightcraft_raw::probe_info(&file).unwrap(); + let full = lightcraft_raw::decode(&file).unwrap(); + let peak = dhat::HeapStats::get().max_bytes; + // the block was still found and decrypted (its content is noise: no SR2SubIFD copies) + for info in [&header, &full.info()] { + let sony = info.lens_metadata.sony.as_ref().expect("a lens record"); + assert!(sony.sr2_read, "the SR2SubIFD was not read"); + } + eprintln!("peak heap {peak} bytes decoding a {} byte file", file.len()); + assert!(peak < 2 << 20, "peak heap {peak} bytes decoding a {} byte file", file.len()); +} diff --git a/crates/segment/Cargo.toml b/crates/segment/Cargo.toml index 11dff5c77..b4614a81d 100644 --- a/crates/segment/Cargo.toml +++ b/crates/segment/Cargo.toml @@ -23,3 +23,7 @@ lightcraft-fetch = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] candle-core = { workspace = true, features = ["metal"] } candle-nn = { workspace = true, features = ["metal"] } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/reference.rs index a73fcaa7c..315d36cd1 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/reference.rs @@ -38,7 +38,7 @@ fn mask_disagreement(a: &[f32], b: &[f32]) -> f32 { #[test] fn matches_reference() { let Some((dir, r)) = setup() else { - eprintln!("skipped: set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); + lightcraft_testkit::skip!(Model, "set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); return; }; let mut model = Sam3::load(&dir).unwrap(); diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..cefa1ef5c --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lightcraft-testkit" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft test tooling (dev-dependency only): a test that can't run here records why, so `cargo xtask ci` reports it" +publish = false + +[lints] +workspace = true + +[dependencies] diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..7a2322245 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,190 @@ +//! Test tooling shared by the workspace's tests. A dev-dependency only: `cargo xtask layers` +//! refuses it as a normal dependency. Std only, so every crate can use it at no build cost. +//! +//! **Skips.** libtest has no "skipped" status and hides the output of passing tests, so a test that +//! needs a GPU, a corpus file, the craft-fonts faces or a model, and returns early without them, +//! passes silently: "all steps passed" could mean the GPU tests never ran. Such a test calls +//! [`skip!`] before it returns: +//! +//! ```no_run +//! # let gpu_available = false; +//! if !gpu_available { +//! lightcraft_testkit::skip!(Gpu, "no GPU adapter"); +//! return; +//! } +//! ``` +//! +//! It prints the note to stderr as before and, when the [`SKIP_LOG_ENV`] variable names a file +//! (`cargo xtask ci` sets it), appends one JSON line there: +//! `{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}`. +//! `cargo xtask ci` lists the skips by need and reason in its summary and in +//! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records +//! the part it could not run the same way. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::Path; + +/// The environment variable naming the file skips are appended to (JSON lines). +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// What a skipped test needed and didn't find. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Need { + /// A usable GPU adapter (`lightcraft_gpu::available()`). + Gpu, + /// A file from the gitignored `corpus/` (`cargo xtask corpus --download`) or a private corpus. + Corpus, + /// Faces from craft-fonts, embedded only in builds with `CRAFT_FONTS_DIR`. + Fonts, + /// A model file named by an environment variable (SAM 3, YuNet, …). + Model, + /// An input file named by an environment variable (opt-in tests). + Input, + /// Something the operating system provides (system ICC profiles, …). + System, + /// Support this build doesn't have yet (a decoder that refuses a file variant, an optional + /// cargo feature that is off). + Feature, +} + +impl Need { + /// The name in the skip log and the CI summary. + pub fn as_str(self) -> &'static str { + match self { + Need::Gpu => "gpu", + Need::Corpus => "corpus", + Need::Fonts => "fonts", + Need::Model => "model", + Need::Input => "input", + Need::System => "system", + Need::Feature => "feature", + } + } +} + +/// Record that this test (or part of it) can't run here: `skip!(Gpu, "no GPU adapter ({why})")`. +/// The first argument names a [`Need`] variant; the rest is a `format!` message. Doesn't return +/// from the test: follow it with `return` (or `continue`) as the test needs. +#[macro_export] +macro_rules! skip { + ($need:ident, $($arg:tt)+) => { + $crate::record($crate::Need::$need, &::std::format!($($arg)+), ::std::file!(), ::std::line!()) + }; +} + +/// What [`skip!`] calls: print the note and append it to the skip log, if one is set. Never fails +/// the test: a log that can't be written loses the entry, not the run. +pub fn record(need: Need, reason: &str, file: &str, line: u32) { + eprintln!("skipped ({}): {reason}", need.as_str()); + let log = std::env::var_os(SKIP_LOG_ENV).filter(|p| !p.is_empty()); + if let Some(log) = log { + let thread = std::thread::current(); + let binary = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| binary_name(&s.to_string_lossy()).to_owned())); + let entry = entry(need, reason, thread.name().unwrap_or(""), binary.as_deref().unwrap_or(""), file, line); + append(Path::new(&log), &entry); + } +} + +/// Append one line in a single write, so lines from parallel tests don't interleave. +fn append(path: &Path, line: &str) { + let file = std::fs::OpenOptions::new().create(true).append(true).open(path); + if let Err(e) = file.and_then(|mut f| f.write_all(line.as_bytes())) { + eprintln!("{SKIP_LOG_ENV}: can't append to {}: {e}", path.display()); + } +} + +/// A test binary's name without the hash cargo appends (`equivalence-0123abcd` → `equivalence`). +fn binary_name(stem: &str) -> &str { + match stem.rsplit_once('-') { + Some((name, hash)) if !name.is_empty() && hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()) => name, + _ => stem, + } +} + +/// One skip-log line (JSON, newline-terminated). +fn entry(need: Need, reason: &str, test: &str, binary: &str, file: &str, line: u32) -> String { + let mut s = String::from("{"); + for (i, (key, value)) in [("need", need.as_str()), ("reason", reason), ("test", test), ("binary", binary)].into_iter().enumerate() { + if i > 0 { + s.push(','); + } + let _ = write!(s, "\"{key}\":\"{}\"", escape(value)); + } + let _ = writeln!(s, ",\"location\":\"{}:{line}\"}}", escape(&file.replace('\\', "/"))); + s +} + +/// A JSON string's contents. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if u32::from(c) < 0x20 => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_entry_is_one_json_line_with_its_text_escaped() { + let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "corpus", "crates\\raw\\tests\\corpus.rs", 28); + assert!(line.ends_with('\n')); + assert_eq!(line.matches('\n').count(), 1, "{line}"); + assert_eq!( + line, + "{\"need\":\"corpus\",\"reason\":\"skip: C:\\\\corpus\\\\raw \\\"absent\\\"\\n\\u0001\",\"test\":\"corpus::decodes\",\ + \"binary\":\"corpus\",\"location\":\"crates/raw/tests/corpus.rs:28\"}\n" + ); + } + + #[test] + fn binary_names_lose_cargos_hash_only() { + assert_eq!(binary_name("equivalence-0f3a9c2b1d4e5f60"), "equivalence"); + assert_eq!(binary_name("lightcraft_engine-0f3a9c2b1d4e5f60"), "lightcraft_engine"); + assert_eq!(binary_name("sparse_corpus"), "sparse_corpus"); + assert_eq!(binary_name("cr3-corpus"), "cr3-corpus"); + assert_eq!(binary_name("-0f3a9c2b1d4e5f60"), "-0f3a9c2b1d4e5f60"); + assert_eq!(binary_name("tool-cafe"), "tool-cafe"); + } + + #[test] + fn appended_lines_accumulate() { + let dir = std::env::temp_dir().join(format!("lightcraft-testkit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("skips.jsonl"); + let _ = std::fs::remove_file(&log); + let threads: Vec<_> = (0..8) + .map(|i| { + let log = log.clone(); + std::thread::spawn(move || append(&log, &entry(Need::Gpu, &format!("no GPU {i}"), "t", "b", "f.rs", i))) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + let text = std::fs::read_to_string(&log).unwrap(); + assert_eq!(text.lines().count(), 8); + assert!(text.lines().all(|l| l.starts_with("{\"need\":\"gpu\"") && l.ends_with('}'))); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_missing_log_directory_does_not_fail_the_test() { + append(Path::new("/nonexistent-lightcraft-dir/skips.jsonl"), "{}\n"); + } +} diff --git a/crates/tiff/README.md b/crates/tiff/README.md index c691dd641..e6a771288 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -18,6 +18,10 @@ later the TIFF/DNG exporters. - `image::ImageInfo` — width/height, bits, samples, compression, photometric, planar config, predictor, sample format and the strip or tile chunk list (`chunks()`, `chunk_bytes()`); missing byte counts are inferred. +- `directory_entries`, `entry_span`, `ParseOptions::budget_for` and `decode_value` are the reader's own rules + (directory layout and entry limit, which entries it reads, its value budget, value decoding), for callers that + must select entries exactly as `parse_ifd_at` would without decoding the whole directory (`lightcraft-raw`'s + Sony `SR2Private` lookup). - `TiffWriter` writes IFD chains with strips or tiles, `SubIFDs` and pointer IFDs, in either byte order, classic or BigTIFF; `writer::rational` / `srational` approximate floats as rationals. @@ -25,7 +29,9 @@ later the TIFF/DNG exporters. The reader never panics on malformed input (proptests feed random and mutated files): offsets are bounds-checked; out-of-range or unknown-type entries are skipped; IFD loops are detected; IFD count, -nesting depth and entries per IFD are limited; the total bytes of decoded values are capped at +nesting depth and entries per IFD are limited; a next-IFD pointer is followed only when every declared entry was +read, with the counts compared as 64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't +pass for a whole directory on a 32-bit target); the total bytes of decoded values are capped at `8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. ## Sources diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index bafa3eb5e..1d0b74d0d 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -25,7 +25,7 @@ mod reader; pub mod tags; pub mod writer; -pub use reader::{ParseOptions, parse_ifd_at}; +pub use reader::{DirectoryEntries, EntrySpan, ParseOptions, decode as decode_value, directory_entries, entry_span, parse_ifd_at}; pub use writer::{IfdBuilder, ImageData, TiffWriter}; use serde::{Deserialize, Serialize}; diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index a644159b7..5f38dfad2 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -26,6 +26,108 @@ impl Default for ParseOptions { } } +impl ParseOptions { + /// The value budget for `data_len` bytes of input: [`Self::value_budget`], else `8 × data_len + 1 MiB`. + pub fn budget_for(&self, data_len: usize) -> u64 { + self.value_budget.unwrap_or((data_len as u64).saturating_mul(8).saturating_add(1 << 20)) + } +} + +/// Where a directory's entries are and how many of them the reader reads, as [`directory_entries`] found them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DirectoryEntries { + /// Position of the first entry. + table: usize, + /// Entries read: the declared count, cut to the entries the data holds and to the `max_entries` limit. + read: usize, + /// The declared entry count. + declared: u64, + /// Bytes per entry: 12, or 20 in BigTIFF. + entry_size: usize, +} + +impl DirectoryEntries { + /// Positions of the entries read, in directory order. Checked arithmetic: the iteration ends rather than + /// overflow, whatever the layout. + pub fn positions(&self) -> impl Iterator + use<> { + let (table, size) = (self.table, self.entry_size); + (0..self.read).map_while(move |i| i.checked_mul(size)?.checked_add(table)) + } + + /// The number of entries read. + pub fn read(&self) -> usize { + self.read + } + + /// The entry count the directory declares. + pub fn declared(&self) -> u64 { + self.declared + } +} + +/// The directory at `offset` as the reader walks it: its entry table and how many entries it reads. The same errors +/// as [`parse_ifd_at`] for a directory it can't read: past the data, empty, or without room for one entry. +pub fn directory_entries(data: &[u8], offset: u64, order: ByteOrder, bigtiff: bool, max_entries: usize) -> Result { + let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; + let (n, head, entry_size) = if bigtiff { + (order.read_u64(data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize) + } else { + (order.read_u16(data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12) + }; + let table = pos + head; + let fit = (data.len().saturating_sub(table) / entry_size) as u64; + let read = n.min(fit).min(max_entries as u64) as usize; + if n == 0 { + return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); + } + if read == 0 { + return Err(TiffError::Truncated(offset)); + } + Ok(DirectoryEntries { table, read, declared: n, entry_size }) +} + +/// A directory entry the reader can read: its tag, type and count, and where its values are. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EntrySpan { + pub tag: u16, + pub field_type: FieldType, + pub count: u64, + /// Position of the values in the data: the entry's own value field when they fit it, else `base` plus the offset + /// stored there. + pub start: u64, + /// `count × size` bytes. + pub len: u64, +} + +/// The entry at `pos`, when the reader reads it: a known field type, `count × size` without overflow, and values +/// inside `data`. Nothing is decoded; [`parse_ifd_at`] also drops an entry whose values exceed what is left of its +/// value budget ([`ParseOptions::budget_for`]), which this leaves to the caller. +pub fn entry_span(data: &[u8], pos: usize, order: ByteOrder, bigtiff: bool, base: u64) -> Option { + let tag = order.read_u16(data, pos)?; + let field_type = FieldType::from_u16(order.read_u16(data, pos.checked_add(2)?)?)?; + let (count, field, inline) = if bigtiff { + (order.read_u64(data, pos.checked_add(4)?)?, pos.checked_add(12)?, 8u64) + } else { + (order.read_u32(data, pos.checked_add(4)?)? as u64, pos.checked_add(8)?, 4) + }; + let len = count.checked_mul(field_type.size() as u64)?; + let start = if len <= inline { + field as u64 + } else { + let rel = if bigtiff { order.read_u64(data, field)? } else { order.read_u32(data, field)? as u64 }; + base.checked_add(rel)? + }; + let end = start.checked_add(len)?; + (end <= data.len() as u64).then_some(EntrySpan { tag, field_type, count, start, len }) +} + +/// Whether a directory declaring `declared` entries was read whole (`read` entries), so that its next-IFD pointer +/// follows them. The counts compare as 64-bit numbers: narrowing the declared count to `usize` instead would, on a +/// 32-bit target, let a BigTIFF declaring 4,294,967,297 entries but holding one pass for a whole directory. +fn reads_every_entry(declared: u64, read: usize) -> bool { + u64::try_from(read).is_ok_and(|read| read == declared) +} + /// Parse the header: (byte order, bigtiff, first IFD offset). pub(crate) fn header(data: &[u8]) -> Result<(ByteOrder, bool, u64)> { header_with(data, true) @@ -97,7 +199,7 @@ struct Ctx<'a> { impl<'a> Ctx<'a> { fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self { - let budget = opts.value_budget.unwrap_or((data.len() as u64).saturating_mul(8).saturating_add(1 << 20)); + let budget = opts.budget_for(data.len()); Self { data, order, big, opts, visited: HashSet::new(), budget } } @@ -116,34 +218,19 @@ impl<'a> Ctx<'a> { if !self.visited.insert(offset) { return Err(TiffError::Invalid(format!("IFD loop at {offset}"))); } - let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; - let (n, head, entry_size, inline) = if self.big { - (self.order.read_u64(self.data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize, 8usize) - } else { - (self.order.read_u16(self.data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12, 4) - }; - let table = pos + head; - let fit = (self.data.len().saturating_sub(table) / entry_size) as u64; - let n_read = n.min(fit).min(self.opts.max_entries as u64) as usize; - if n == 0 { - return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); - } - if n_read == 0 { - return Err(TiffError::Truncated(offset)); - } - let mut entries = Vec::with_capacity(n_read); - for i in 0..n_read { - let p = table + i * entry_size; - if let Some(e) = self.entry(p, base, inline) { + let dir = directory_entries(self.data, offset, self.order, self.big, self.opts.max_entries)?; + let mut entries = Vec::with_capacity(dir.read); + for p in dir.positions() { + if let Some(e) = self.entry(p, base) { entries.push(e); } } entries.sort_by_key(|e| e.tag); entries.dedup_by_key(|e| e.tag); - // the next-IFD offset follows the whole table, so only a table read in full has one: a count far past the - // file (a BigTIFF's is 64-bit) must not reach the arithmetic (issue #1516) - let next = if n == n_read as u64 { - match n_read.checked_mul(entry_size).and_then(|len| table.checked_add(len)).and_then(|p| self.read_off(p)) { + // the next-IFD pointer follows the last entry, read only when every declared entry was (checked: a BigTIFF + // count can be anything up to u64::MAX) + let next = if reads_every_entry(dir.declared, dir.read) { + match dir.read.checked_mul(dir.entry_size).and_then(|n| n.checked_add(dir.table)).and_then(|p| self.read_off(p)) { Some(0) | None => 0, Some(v) => base.checked_add(v).unwrap_or(0), } @@ -181,33 +268,19 @@ impl<'a> Ctx<'a> { ifd.interop = interop; } - fn entry(&mut self, p: usize, base: u64, inline: usize) -> Option { - let o = self.order; - let tag = o.read_u16(self.data, p)?; - let typ = FieldType::from_u16(o.read_u16(self.data, p + 2)?)?; - let (count, field) = if self.big { (o.read_u64(self.data, p + 4)?, p + 12) } else { (o.read_u32(self.data, p + 4)? as u64, p + 8) }; - let total = count.checked_mul(typ.size() as u64)?; - let start = if total <= inline as u64 { - field as u64 - } else { - let rel = self.read_off(field)?; - base.checked_add(rel)? - }; - let end = start.checked_add(total)?; - if end > self.data.len() as u64 { + fn entry(&mut self, p: usize, base: u64) -> Option { + let span = entry_span(self.data, p, self.order, self.big, base)?; + if span.len > self.budget { return None; } - if total > self.budget { - return None; - } - self.budget -= total; - let bytes = &self.data[start as usize..end as usize]; - Some(Entry { tag, value: decode(o, typ, bytes), offset: start }) + self.budget -= span.len; + let bytes = self.data.get(usize::try_from(span.start).ok()?..usize::try_from(span.start + span.len).ok()?)?; + Some(Entry { tag: span.tag, value: decode(self.order, span.field_type, bytes), offset: span.start }) } } -/// Decode `bytes` (exactly `count × size` long) as values of type `typ`. -pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { +/// Decode `bytes` (exactly `count × size` long, or the first values of an entry) as values of type `typ`. +pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { fn arr(c: &[u8]) -> [u8; N] { let mut a = [0u8; N]; a.copy_from_slice(c); @@ -237,3 +310,99 @@ pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { FieldType::SLong8 => Value::SLong8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c)) as i64).collect()), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer + /// `next`; a valid one-entry IFD sits at 16. + fn bigtiff_chain(order: ByteOrder, declared: u64, next: u64) -> Vec { + let ifd = |d: &mut Vec, count: u64, tag: u16, next: u64| { + order.put_u64(d, count); + order.put_u16(d, tag); + order.put_u16(d, 3); + order.put_u64(d, 1); + order.put_u16(d, 8); + d.extend([0; 6]); + order.put_u64(d, next); + }; + let mut d = if order == ByteOrder::Little { b"II".to_vec() } else { b"MM".to_vec() }; + order.put_u16(&mut d, 43); + order.put_u16(&mut d, 8); + order.put_u16(&mut d, 0); + order.put_u64(&mut d, 80); + ifd(&mut d, 1, 0x0101, 0); + d.resize(80, 0); + ifd(&mut d, declared, 0x0100, next); + d + } + + #[test] + fn only_a_directory_read_whole_has_a_next_pointer() { + // the comparison itself, on every target: a declared count past u32::MAX never matches a small read count + for (declared, read, whole) in [ + (0, 0, true), + (1, 1, true), + (8192, 8192, true), + (8193, 8192, false), + (1, 0, false), + ((1 << 32) + 1, 1, false), + ((2 << 32) + 1, 1, false), + ((1 << 63) + 1, 1, false), + (u64::MAX, 1, false), + ] { + assert_eq!(reads_every_entry(declared, read), whole, "{declared} declared, {read} read"); + } + } + + #[test] + fn a_bigtiff_count_past_u32_max_never_follows_the_next_pointer() { + // the whole chain: on 32-bit targets (wasm32) the old cast truncated these counts to 1 and followed the pointer + for order in [ByteOrder::Little, ByteOrder::Big] { + for declared in [(1u64 << 32) + 1, (2u64 << 32) + 1, (1u64 << 63) + 1] { + let d = bigtiff_chain(order, declared, 16); + let tiff = Tiff::parse(&d).unwrap(); + assert_eq!(tiff.ifds.len(), 1, "{order:?} {declared}"); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + assert_eq!(parse_ifd_at(&d, 80, order, 0, true, &ParseOptions::default()).unwrap().1, 0, "{order:?} {declared}"); + } + // control: the same file declaring its one entry follows the pointer to the IFD at 16 + let whole = Tiff::parse(&bigtiff_chain(order, 1, 16)).unwrap(); + assert_eq!(whole.ifds.len(), 2, "{order:?}"); + assert_eq!(whole.ifds[1].u64(0x0101), Some(8)); + } + } + + #[test] + fn directory_positions_never_overflow() { + // a hand-built layout the reader never produces: the iterator stops instead of overflowing + let hostile = DirectoryEntries { table: usize::MAX, read: 2, declared: 2, entry_size: 12 }; + assert_eq!(hostile.positions().collect::>(), vec![usize::MAX]); + assert_eq!(hostile.positions().nth(1), None); + let wide = DirectoryEntries { table: usize::MAX - 30, read: 4, declared: 4, entry_size: 12 }; + assert_eq!(wide.positions().collect::>(), vec![usize::MAX - 30, usize::MAX - 18, usize::MAX - 6]); + // a directory read from data lists its entries + let mut data = Vec::new(); + ByteOrder::Little.put_u16(&mut data, 2); + data.extend([0; 24]); + let dir = directory_entries(&data, 0, ByteOrder::Little, false, 8192).unwrap(); + assert_eq!(dir.positions().collect::>(), vec![2, 14]); + } + + #[test] + fn a_bigtiff_directory_declaring_u64_max_entries_parses_without_overflow() { + // 44 bytes: BigTIFF header (first IFD at 16), an entry count of u64::MAX, one 20-byte entry + let mut data = b"II\x2b\x00\x08\x00\x00\x00".to_vec(); + data.extend(16u64.to_le_bytes()); + data.extend(u64::MAX.to_le_bytes()); + data.extend(0x0100u16.to_le_bytes()); + data.extend(3u16.to_le_bytes()); + data.extend(1u64.to_le_bytes()); + data.extend([8, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(data.len(), 44); + let tiff = Tiff::parse(&data).unwrap(); + assert_eq!(tiff.ifds.len(), 1); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 4dc4a95d3..1ae33a03d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -28,6 +28,8 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index 68bcbab88..387922e63 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -289,5 +289,7 @@ "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages" } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index f198457bb..c1312faef 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -36,6 +36,7 @@ "Build Standard-Sized Previews ({scope})": "Standardvorschauen erstellen ({scope})", "Building previews for {} photos…": "Vorschauen für {} Fotos werden erstellt…", "Click {}:1": "Klick {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Farbraum: Rec. 2020 {curve} (HDR-AVIF)", "Couldn't open the editor: {e}": "Editor konnte nicht geöffnet werden: {e}", "Create {n} Virtual Copies": "{n} virtuelle Kopien erstellen", "Creates {} stacks from {} of {scope}": "Erstellt {} Stapel aus {} von {scope}", @@ -162,6 +163,7 @@ "{n} photo{}": "{n} Foto{}", "{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", "{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", + "{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten", "{pid} (missing)": "{pid} (fehlt)", "{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index e1168f126..a766aa662 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -14,6 +14,7 @@ "0: nothing is rejected.": "0: Nichts wird abgelehnt.", "1 day": "1 Tag", "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 auf studio-mac. Beide Programme können nicht gleichzeitig schreiben und gegenseitig Bearbeitungen verlieren. Ein Absturz lässt die", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Beschnitt", "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 großen Vorschau. Metadaten werden importiert: Aufnahmezeit, Kamera, Objektiv, Belichtung, GPS und XMP. Die RAW-Daten", "2026-04 or 2026-04-12": "2026-04 oder 2026-04-12", "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 neue eingebaute Vorgaben: Porträt, Landschaft, Stadt, Essen, Jahreszeiten, Vintage und Schwarzweißtonungen.", @@ -219,6 +220,7 @@ "Choose Edit Settings to Copy": "Zu kopierende Bearbeitungseinstellungen auswählen", "Choose Edit Settings to Copy…": "Zu kopierende Bearbeitungseinstellungen auswählen…", "Choose Folder…": "Ordner auswählen…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Nach dem Bestätigen den Speicherort des PDFs wählen. Das PDF aus dem Anzeigeprogramm drucken.", "Choose a photo in the grid or filmstrip": "Wähle ein Foto im Raster oder Filmstreifen", "Choose…": "Auswählen…", "Cinematic": "Filmisch", @@ -270,6 +272,9 @@ "Color Range": "Farbbereich", "Color label cleared": "Farbmarkierung entfernt", "Color space": "Farbraum", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Farbraum: Rec. 2020 PQ (HDR-AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Farbraum: Rec. 2020 PQ (HDR-JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Farbraum: Rec. 2020 PQ, 16 Bit (HDR-PNG)", "Color space: sRGB (AVIF)": "Farbraum: sRGB (AVIF)", "Colors": "Farben", "Colour-label filter with several labels at once; expandable folder tree in Local.": "Farbmarkierungsfilter mit mehreren Markierungen gleichzeitig; aufklappbarer lokaler Ordnerbaum.", @@ -290,6 +295,7 @@ "Confirm before deleting": "Vor dem Löschen bestätigen", "Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen", "Constrain Crop": "Zuschnitt beschränken", + "Contact Sheet PDF": "Kontaktabzug-PDF", "Continue": "Fortfahren", "Continue Without Saving": "Ohne Speichern fortfahren", "Contrast": "Kontrast", @@ -523,6 +529,7 @@ "File name": "Dateiname", "File types:": "Dateitypen:", "Filename": "Dateiname", + "Filename captions": "Dateinamen als Bildunterschrift", "Fill": "Ausfüllen", "Fill Edges": "Ränder füllen", "Film": "Film", @@ -583,6 +590,9 @@ "Full": "Vollständig", "Full Screen Preview": "Vollbildvorschau", "Hold View Until Refreshed": "Ansicht bis zum Aktualisieren beibehalten", + "HDR TIFF needs 32-bit float.": "HDR-TIFF braucht 32-Bit-Gleitkomma.", + "HDR output": "HDR-Ausgabe", + "Letter": "Letter", "Lights Out": "Licht aus", "Full screen mode": "Vollbildmodus", "Full size": "Volle Größe", @@ -959,6 +969,11 @@ "No new presets": "Keine neuen Vorgaben", "No photo selected": "Kein Foto ausgewählt", "No photos": "Keine Fotos", + "Your library is empty": "Deine Bibliothek ist leer", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Nichts wird kopiert oder verschoben, außer du wählst es.", + "or drop photos and folders into this window": "oder ziehe Fotos und Ordner in dieses Fenster", + "Import photos to get started": "Importiere Fotos, um loszulegen", + "or drop photos into this window": "oder ziehe Fotos in dieses Fenster", "No photos found": "Keine Fotos gefunden", "No photos in this folder": "Keine Fotos in diesem Ordner", "No photos match the active filters": "Keine Fotos entsprechen den aktiven Filtern", @@ -1051,6 +1066,7 @@ "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "Fotomenü. Auch der Filmstreifen besitzt das Fotokontextmenü. Wird eine Datei erneut hinzugefügt, die unter „Zuletzt gelöscht“ liegt,", "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → In externem Editor bearbeiten (⇧⌘E) rendert eine 16-Bit-TIFF-Kopie, stapelt sie mit dem Original und öffnet sie hier (App-Name unter macOS, sonst Programmpfad).", "Photos": "Fotos", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "In HDR bearbeitete Fotos: JPEG mit HDR-Gain-Map (sieht überall richtig aus, hellere Lichter auf HDR-Bildschirmen), AVIF mit 10 Bit, PNG und JPEG XL mit 16 Bit Rec. 2020 PQ; TIFF braucht 32-Bit-Gleitkomma", "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Fotos unter „Zuletzt gelöscht“ können in der App wiederhergestellt werden: Rechtsklick → Wiederherstellen oder Endgültig löschen, auch im", "Photos per bracket (0 = auto)": "Fotos pro Belichtungsreihe (0 = automatisch)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotos, die unter „Lokal“ angezeigt, aber nie hinzugefügt oder geändert wurden, verlassen den Katalog, wenn ihr Ordner so lange nicht durchsucht wurde (Prüfung beim Öffnen der Bibliothek). Dateien bleiben auf dem Datenträger; beim Durchsuchen erscheinen sie wieder.", @@ -1590,6 +1606,7 @@ "Vibrance": "Dynamik", "Videos": "Videos", "View": "Ansicht", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Programme, die den cICP-Block des PNGs nicht lesen, zeigen PQ-Werte als sRGB: flau und blass. Für ein Bild, das überall richtig aussieht, JPEG (Gain-Map) verwenden.", "Viewing and sharing": "Ansicht und Weitergabe", "Vignette": "Vignette", "Vignetting": "Vignettierung", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 8274ed8be..2d307f5d8 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -36,6 +36,7 @@ "Build Standard-Sized Previews ({scope})": "Generar vistas previas de tamaño estándar ({scope})", "Building previews for {} photos…": "Generando vistas previas de {} fotos…", "Click {}:1": "Clic {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Espacio de color: Rec. 2020 {curve} (AVIF HDR)", "Couldn't open the editor: {e}": "No se pudo abrir el editor: {e}", "Create {n} Virtual Copies": "Crear {n} copias virtuales", "Creates {} stacks from {} of {scope}": "Crea {} pilas a partir de {} de {scope}", @@ -162,6 +163,7 @@ "{n} photo{}": "{n} foto{}", "{n} photo{} match · updates automatically as photos change": "{n} foto{0} coincidente{0} · se actualiza automáticamente cuando cambian las fotos", "{n} photo{} now · updates automatically as photos change": "{n} foto{} ahora · se actualiza automáticamente cuando cambian las fotos", + "{n} selected photos · {pages} pages": "{n} fotos seleccionadas · {pages} páginas", "{pid} (missing)": "{pid} (falta)", "{sel_n} selected · {counted}": "{sel_n} seleccionadas · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 77a175721..86e89076d 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -8,6 +8,7 @@ "+ Rule": "+ Regla", "0: nothing is rejected.": "0: no se rechaza nada.", "1 day": "1 día", + "150 dpi · sRGB · current edits · fit without cropping": "150 ppp · sRGB · ediciones actuales · ajustado sin recortar", "2026-04 or 2026-04-12": "2026-04 o 2026-04-12", "= exactly": "= exactamente", "A blank template keeps the original names.": "Una plantilla vacía conserva los nombres originales.", @@ -205,6 +206,7 @@ "Choose Edit Settings to Copy…": "Elegir ajustes de edición para copiar…", "Choose Face Recognition Model": "Elegir el modelo de reconocimiento facial", "Choose Folder…": "Elegir carpeta…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Elige dónde guardar el PDF después de confirmar. Imprime el PDF desde tu visor.", "Choose a model below (Use) to start.": "Elegir un modelo abajo (Usar) para empezar.", "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Mayús+R, conserva la foto y borra sus ediciones).", "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtro de etiquetas de color con varias etiquetas a la vez; árbol de carpetas desplegable en Local.", @@ -262,6 +264,9 @@ "Color Range": "Rango de color", "Color label cleared": "Etiqueta de color eliminada", "Color space": "Espacio de color", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espacio de color: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espacio de color: Rec. 2020 PQ (JPEG XL HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espacio de color: Rec. 2020 PQ, 16 bits (PNG HDR)", "Color space: sRGB (AVIF)": "Espacio de color: sRGB (AVIF)", "Colors": "Colores", "Combine files…": "Combinar archivos…", @@ -279,6 +284,7 @@ "Confirm before deleting": "Confirmar antes de eliminar", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover fotos a Eliminadas recientemente", "Constrain Crop": "Restringir recorte", + "Contact Sheet PDF": "PDF de hoja de contactos", "Continue": "Continuar", "Continue Without Saving": "Continuar sin guardar", "Contrast": "Contraste", @@ -521,6 +527,7 @@ "File name": "Nombre de archivo", "File types:": "Tipos de archivo:", "Filename": "Nombre de archivo", + "Filename captions": "Pies con el nombre de archivo", "Fill": "Rellenar", "Fill Edges": "Rellenar bordes", "Film": "Película", @@ -581,6 +588,9 @@ "Full": "Completa", "Full Screen Preview": "Vista previa a pantalla completa", "Hold View Until Refreshed": "Mantener vista hasta actualizar", + "HDR TIFF needs 32-bit float.": "El TIFF HDR necesita coma flotante de 32 bits.", + "HDR output": "Salida HDR", + "Letter": "Carta", "Lights Out": "Luces apagadas", "Full screen mode": "Modo de pantalla completa", "Full size": "Tamaño completo", @@ -966,6 +976,11 @@ "No new presets": "No hay ajustes preestablecidos nuevos", "No photo selected": "No hay ninguna foto seleccionada", "No photos": "Sin fotos", + "Your library is empty": "Tu biblioteca está vacía", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Las fotos que importes aparecen aquí. No se copia ni se mueve nada a menos que lo elijas.", + "or drop photos and folders into this window": "o arrastra fotos y carpetas a esta ventana", + "Import photos to get started": "Importa fotos para empezar", + "or drop photos into this window": "o arrastra fotos a esta ventana", "No photos found": "No se encontraron fotos", "No photos in this folder": "No hay fotos en esta carpeta", "No photos match the active filters": "Ninguna foto coincide con los filtros activos", @@ -1056,6 +1071,7 @@ "Photo Merge": "Combinación de fotos", "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Editar en el editor externo (⇧⌘E) renderiza una copia TIFF de 16 bits, la apila con el original y la abre aquí (un nombre de aplicación en macOS, una ruta de programa en otros sistemas).", "Photos": "Fotos", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotos editadas en HDR: JPEG con mapa de ganancia HDR (se ve bien en todas partes, con luces más brillantes en pantallas HDR), AVIF a 10 bits, PNG y JPEG XL a 16 bits Rec. 2020 PQ; TIFF necesita coma flotante de 32 bits", "Photos per bracket (0 = auto)": "Fotos por horquillado (0 = automático)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Las fotos vistas en Local, pero nunca añadidas ni modificadas, salen del catálogo cuando su carpeta no se ha explorado durante este tiempo (se comprueba al abrir la biblioteca). Los archivos permanecen en el disco; al explorar la carpeta vuelven a aparecer.", "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Las fotos que llegan a esta carpeta (captura conectada, un escáner, una aplicación de sincronización) se añaden en cuanto están completas.", @@ -1696,6 +1712,7 @@ "Vibrance": "Intensidad", "Videos": "Vídeos", "View": "Vista", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Los visores que no leen el bloque cICP del PNG muestran los valores PQ como sRGB: planos y desvaídos. Para una imagen que se vea bien en todas partes, usa JPEG (mapa de ganancia).", "Viewing and sharing": "Visualización y uso compartido", "Vignette": "Viñeta", "Vignetting": "Viñeteado", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 4a5716b0c..37f970615 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -36,6 +36,7 @@ "Build Standard-Sized Previews ({scope})": "Créer les aperçus de taille standard ({scope})", "Building previews for {} photos…": "Création des aperçus pour {} photos…", "Click {}:1": "Cliquer sur {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Espace colorimétrique : Rec. 2020 {curve} (AVIF HDR)", "Couldn't open the editor: {e}": "Impossible d’ouvrir l’éditeur : {e}", "Create {n} Virtual Copies": "Créer {n} copies virtuelles", "Creates {} stacks from {} of {scope}": "Crée {} piles à partir de {} parmi {scope}", @@ -162,6 +163,7 @@ "{n} photo{}": "{n} photo{}", "{n} photo{} match · updates automatically as photos change": "{n} photo{} correspondante(s) · mise à jour automatique lorsque les photos changent", "{n} photo{} now · updates automatically as photos change": "{n} photo{} actuellement · mise à jour automatique lorsque les photos changent", + "{n} selected photos · {pages} pages": "{n} photos sélectionnées · {pages} pages", "{pid} (missing)": "{pid} (manquant)", "{sel_n} selected · {counted}": "{sel_n} sélectionné(s) · {counted}", "{stage}… {:.0}%": "{stage}… {:.0} %", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index d70878dfd..c18889bd4 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -8,6 +8,7 @@ "+ Rule": "+ Règle", "0: nothing is rejected.": "0 : rien n'est rejeté.", "1 day": "1 jour", + "150 dpi · sRGB · current edits · fit without cropping": "150 ppp · sRVB · retouches actuelles · ajusté sans recadrage", "2026-04 or 2026-04-12": "2026-04 ou 2026-04-12", "= exactly": "= exactement", "A blank template keeps the original names.": "Un modèle vide conserve les noms d'origine.", @@ -198,6 +199,7 @@ "Choose Edit Settings to Copy": "Choisir les réglages de retouche à copier", "Choose Edit Settings to Copy…": "Choisir les réglages de retouche à copier…", "Choose Folder…": "Choisir un dossier…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Choisissez l’emplacement du PDF après confirmation. Imprimez le PDF depuis votre visionneuse.", "Choose a photo in the grid or filmstrip": "Choisissez une photo dans la grille ou la pellicule", "Choose…": "Choisir…", "Cinematic": "Cinématographique", @@ -247,6 +249,9 @@ "Color Range": "Plage de couleurs", "Color label cleared": "Étiquette de couleur supprimée", "Color space": "Espace colorimétrique", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espace colorimétrique : Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espace colorimétrique : Rec. 2020 PQ (JPEG XL HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espace colorimétrique : Rec. 2020 PQ, 16 bits (PNG HDR)", "Color space: sRGB (AVIF)": "Espace colorimétrique : sRGB (AVIF)", "Colors": "Couleurs", "Combine files…": "Combiner des fichiers…", @@ -264,6 +269,7 @@ "Confirm before deleting": "Confirmer avant de supprimer", "Confirm before moving photos to Recently Deleted": "Confirmer avant de déplacer des photos vers Supprimées récemment", "Constrain Crop": "Contraindre le recadrage", + "Contact Sheet PDF": "Planche contact PDF", "Continue": "Continuer", "Continue Without Saving": "Continuer sans enregistrer", "Contrast": "Contraste", @@ -488,6 +494,7 @@ "File name": "Nom de fichier", "File types:": "Types de fichiers :", "Filename": "Nom de fichier", + "Filename captions": "Légendes avec le nom de fichier", "Fill": "Remplir", "Fill Edges": "Remplir les bords", "Film": "Pellicule", @@ -547,6 +554,9 @@ "Full": "Complet", "Full Screen Preview": "Aperçu en plein écran", "Hold View Until Refreshed": "Conserver la vue jusqu’à l’actualisation", + "HDR TIFF needs 32-bit float.": "Le TIFF HDR exige du 32 bits flottant.", + "HDR output": "Sortie HDR", + "Letter": "Lettre US", "Lights Out": "Lumières éteintes", "Full screen mode": "Mode plein écran", "Full size": "Taille complète", @@ -911,6 +921,11 @@ "No new presets": "Aucun nouveau préréglage", "No photo selected": "Aucune photo sélectionnée", "No photos": "Aucune photo", + "Your library is empty": "Votre bibliothèque est vide", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Les photos importées apparaissent ici. Rien n’est copié ni déplacé, sauf si vous le choisissez.", + "or drop photos and folders into this window": "ou déposez des photos et des dossiers dans cette fenêtre", + "Import photos to get started": "Importez des photos pour commencer", + "or drop photos into this window": "ou déposez des photos dans cette fenêtre", "No photos found": "Aucune photo trouvée", "No photos in this folder": "Aucune photo dans ce dossier", "No photos match the active filters": "Aucune photo ne correspond aux filtres actifs", @@ -1000,6 +1015,7 @@ "Photo Merge": "Fusion de photos", "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Photo → Modifier dans l'éditeur externe (⇧⌘E) génère une copie TIFF 16 bits, l'empile avec l'original et l'ouvre ici (un nom d'application sur macOS, un chemin de programme ailleurs).", "Photos": "Photos", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Photos retouchées en HDR : JPEG avec carte de gain HDR (correct partout, hautes lumières plus vives sur les écrans HDR), AVIF en 10 bits, PNG et JPEG XL en 16 bits Rec. 2020 PQ ; le TIFF exige du 32 bits flottant", "Photos per bracket (0 = auto)": "Photos par bracketing (0 = auto)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Les photos vues dans Local mais jamais ajoutées ni modifiées quittent le catalogue lorsque leur dossier n'a pas été parcouru depuis cette durée (vérifié à l'ouverture de la bibliothèque). Les fichiers restent sur le disque ; parcourir le dossier les fait réapparaître.", "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Les photos qui arrivent dans ce dossier (capture connectée, scanner, application de synchronisation) sont ajoutées dès qu'elles sont complètes.", @@ -1518,6 +1534,7 @@ "Vibrance": "Vibrance", "Videos": "Vidéos", "View": "Affichage", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Les visionneuses qui ne lisent pas le bloc cICP du PNG affichent les valeurs PQ comme du sRVB : plates et délavées. Pour une image correcte partout, utilisez le JPEG (carte de gain).", "Viewing and sharing": "Affichage et partage", "Vignette": "Vignette", "Vignetting": "Vignetage", diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json new file mode 100644 index 000000000..70a1bafbe --- /dev/null +++ b/crates/ui-egui/locales/it-formats.json @@ -0,0 +1,295 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} con diversi file simili: usa Trova)", + " ({} in Recently Deleted)": " ({} in Eliminate di recente)", + " · {:.0}% clipped": " · {:.0}% tagliato", + " · {failed} couldn't be rendered": " · impossibili da elaborare: {failed}", + " · {skipped} skipped (file exists)": " · saltate: {skipped} (il file esiste)", + " · {} duplicate{} skipped": " · duplicati saltati: {}{:.0}", + " · {} failed: {}": " · {} con errori: {}", + " · {} moved": " · spostate: {}", + " · {} not readable": " · illeggibili: {}", + " · {} original{} left at the source": " · originali lasciati nell'origine: {}{:.0}", + " · {} restored from Recently Deleted": " · ripristinate da Eliminate di recente: {}", + " — not carried over: {}{}": " · non trasferito: {}{}", + "+{} more": "+{} altre", + ", {failed} file{} not readable": ", file illeggibili: {failed}{:.0}", + "; {kept} already had a location": "; avevano già una posizione: {kept}", + "; {outside} outside its time range": "; fuori dal loro intervallo di tempo: {outside}", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commit, +{} / −{} righe (Δ {}), +{} / −{} risorse binarie, {} - {}", + "Add {local_n} to My Photos": "Aggiungi {local_n} a Le mie foto", + "Add {} Photo{}": "Aggiungi {} foto{:.0}", + "Add {} Photo{}, restore {}": "Aggiungi {} foto{:.0}, ripristina {}", + "Add “{}” to Favorites": "Aggiungi “{}” ai preferiti", + "Added {n} photo{} to My Photos": "Foto aggiunte a Le mie foto: {n}{:.0}", + "Added {n} photo{} to “{}”": "Foto aggiunte a “{1}”: {n}{0:.0}", + "Added {what} to {name}": "Aggiunto a {name}: {what}", + "Adding photos… {} of {}": "Aggiunta delle foto… {} di {}", + "All {n} selected photos move by the same amount.": "Tutte le {n} foto selezionate si spostano della stessa quantità.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Già nella libreria, in Eliminate di recente: foto selezionate {}{:.0} · clic destro → Ripristina, oppure Elimina definitivamente per importarle da capo", + "Already in the library: {} photo{} selected in All Photos": "Già nella libreria (selezionate in Tutte le foto): {}{:.0}", + "Auto ({})": "Automatico ({})", + "Auto Import: added {} photo{}": "Importazione automatica · foto aggiunte: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Sincronizzazione automatica: le modifiche si applicano a {n} foto", + "Automatic ({} MB)": "Automatico ({} MB)", + "Build 1:1 Previews ({scope})": "Genera anteprime 1:1 ({scope})", + "Build Smart Previews ({scope})": "Genera anteprime avanzate ({scope})", + "Build Standard-Sized Previews ({scope})": "Genera anteprime di dimensione standard ({scope})", + "Building previews for {} photos…": "Generazione delle anteprime per {} foto…", + "Click {}:1": "Clic {}:1", + "Couldn't open the editor: {e}": "Impossibile aprire l'editor: {e}", + "Create {n} Virtual Copies": "Crea {n} copie virtuali", + "Creates {} stacks from {} of {scope}": "Crea pile: {} (da {} di {scope})", + "Decoder: {reason}": "Decodificatore: {reason}", + "Delete \"{label}\"": "Elimina \"{label}\"", + "Delete \"{label}\" (and the mask)": "Elimina \"{label}\" (e la maschera)", + "Delete {n} Photos": "Elimina {n} foto", + "Delete “{current}”": "Elimina “{current}”", + "Discard Smart Previews ({scope})": "Elimina anteprime avanzate ({scope})", + "Done ({what}): {done} photo{plural}": "Fatto ({what}): {done} foto{plural:.0}", + "Duplicate \"{label}\"": "Duplica \"{label}\"", + "Example: {example}": "Esempio: {example}", + "Exported {n} photo{}": "Foto esportate: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Foto esportate: {ok} di {total}{:.0}", + "Exported {} point curve preset{}": "Preset di curva a punti esportati: {}{:.0}", + "Exported {} preset{}": "Preset esportati: {}{:.0}", + "Exporting {} of {total}": "Esportazione {} di {total}", + "Filter bar — {active} active filter{}": "Barra dei filtri · filtri attivi: {active}{:.0}", + "Focus {:.0}": "Messa a fuoco {:.0}", + "Folder “{}” (and its subfolders)": "Cartella “{}” (e sottocartelle)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Foto mancanti ritrovate: {n}{:.0}; ancora mancanti: {left}{unsure}", + "Healed {n} dust spot{}": "Macchie di polvere corrette: {n}{:.0}", + "Import cancelled · {} photo{} added": "Importazione annullata · foto aggiunte: {}{:.0}", + "Import failed: {e}": "Importazione non riuscita: {e}", + "Import warnings ({n}):\n{warnings}": "Avvisi di importazione ({n}):\n{warnings}", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Il profilo XMP \"{profile}\" non è stato applicato a {path}; i colori possono differire da Lightroom.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometria di Lightroom non è stata conservata per {path}; {mode} verrà ricalcolato.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata viene ignorata perché il raddrizzamento è disattivato.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata del raddrizzamento guidato non è supportata.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata del raddrizzamento non è supportata.", + "XMP adjustments were not imported for {path}: {fields}.": "Le regolazioni XMP non sono state importate per {path}: {fields}.", + "Imported {n} point curve preset{}": "Preset di curva a punti importati: {n}{:.0}", + "Imported {n} preset{}": "Preset importati: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Preset importati: {n}{:.0}, file illeggibili: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Profili importati: {profiles}{:.0} (Browser dei profili → i loro gruppi)", + "Imported {} photo{}": "Foto importate: {}{:.0}", + "Insert at the cursor (also written {})": "Inserisci al cursore (si scrive anche {})", + "Label Set: {name}": "Set di etichette: {name}", + "LightCraft could not open its window: {e}": "LightCraft non è riuscito ad aprire la sua finestra: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft non è riuscito ad aprire la sua finestra: {e}\n\nI dettagli sono nel file di log: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catalogo di Lightroom esaminato: foto {photos}, raccolte {collections}, mancanti {missing}", + "Lightroom import complete: {imported} photos added": "Importazione da Lightroom completata: foto aggiunte {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Importazione da Lightroom completata: foto aggiunte {imported}, con errori {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Importazione da Lightroom completata: foto aggiunte {imported}; avviso sull'indice: {warning}", + "Lightroom import failed: {e}{warning}": "Importazione da Lightroom non riuscita: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Analisi di Lightroom non riuscita: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Caratteri: {} (OFL). Icone: originali.", + "Matches: {}": "Corrispondenze: {}", + "Merging… {stage} {:.0}%": "Unione in corso… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Spostare {what} in Eliminate di recente?", + "Named ({named_n})": "Con nome ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Pennello “{k}”: fai clic sulle foto per aggiungerla o toglierla · Esc interrompe", + "Paste into {n} photo{} — only settings that were copied are pasted": "Incolla in {n} foto{:.0}: vengono incollate solo le impostazioni copiate", + "Preset: {name}": "Preset: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera ({})", + "Preview only — {}": "Solo anteprima: {}", + "Previews ready for {done} photo{plural}": "Anteprime pronte per {done} foto{plural:.0}", + "Profile: {}": "Profilo: {}", + "Proof Copy ({})": "Copia di prova ({})", + "Proof Preview · {}": "Anteprima di prova · {}", + "Quick Develop · {n} photos": "Sviluppo rapido · {n} foto", + "Quick Develop: {name}": "Sviluppo rapido: {name}", + "Rated {}": "Valutata {}", + "Reading Lightroom catalog… {done} of {total}": "Lettura del catalogo Lightroom… {done} di {total}", + "Reading photos… {done} of {total}": "Lettura delle foto… {done} di {total}", + "Reading photos… {} of {}": "Lettura delle foto… {} di {}", + "Redo {}": "Ripeti {}", + "Remove “{name}” and its {count} photo{} from the library?": "Togliere dalla libreria “{name}” e le sue foto ({count}{:.0})?", + "Remove “{}” from Favorites": "Rimuovi “{}” dai preferiti", + "Removed from {}": "Rimosso da {}", + "Removed {what} from {name}": "Rimosso da {name}: {what}", + "Rename {n} Photos…": "Rinomina {n} foto…", + "Rename “{name}”": "Rinomina “{name}”", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Rinomina “{from}” in {n} foto{:.0} (anche le parole chiave sotto di essa). Usa | per i livelli, es. Viaggi|Italia. Un nome esistente unisce le due.", + "Rendering on the CPU: {why}": "Rendering sulla CPU: {why}", + "Replace {} with:": "Sostituisci {} con:", + "Reset {name}": "Reimposta {name}", + "Reset {} Channel": "Reimposta canale {}", + "Restore {}": "Ripristina {}", + "Restore {} Photo{}": "Ripristina {} foto{:.0}", + "Restored {} photo{} from Recently Deleted": "Foto ripristinate da Eliminate di recente: {}{:.0}", + "Saved export preset “{}”": "Preset di esportazione “{}” salvato", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Valuta messa a fuoco ed esposizione di {scope} e trova scatti simili fatti a pochi secondi di distanza (raffiche).", + "Show {hidden} hidden location{}": "Mostra le posizioni nascoste: {hidden}{:.0}", + "Showing {n} of {}": "Mostrate {n} di {}", + "Smart album: {}": "Album smart: {}", + "Tagged {n} photo{} from the tracklog": "Foto geolocalizzate dal tracciato: {n}{:.0}", + "The SAM 3 download failed: {e}": "Download di SAM 3 non riuscito: {e}", + "Undo {}": "Annulla {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Tag sconosciuti{s:.0}: {} (restano come li hai scritti{:.0}; vedi Tag)", + "Updated “{name}”": "“{name}” aggiornato", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versione {}: libreria fotografica e sviluppo raw clean-room, in puro Rust.", + "Version: {}": "Versione: {}", + "Virtual copy “{name}” of {of}": "Copia virtuale “{name}” di {of}", + "Working on {what} for {} photos…": "Elaborazione di {what} per {} foto…", + "none (CPU rendering): {why}": "nessuna (rendering sulla CPU): {why}", + "the {n} selected photos": "le {n} foto selezionate", + "the {} photos in view": "le {} foto nella vista", + "these {count} photos": "queste {count} foto", + "{count} smart preview{} · {:.1} MB": "Anteprime avanzate: {count}{0:.0} · {1:.1} MB", + "{f} folders (and their subfolders)": "{f} cartelle (e le loro sottocartelle)", + "{label} Grading": "Gradazione di {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} su ogni foto selezionata", + "{label} {index} of {n}": "{label} {index} di {n}", + "{label}, {n} photos": "{label}, {n} foto", + "{matching} of {t} photos": "{matching} di {t} foto", + "{matching} photos": "{matching} foto", + "{mode} spot {} of {}": "{mode}: macchia {} di {}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} con errori)", + "{name} Label": "Etichetta {name}", + "{name} Off": "{name}: disattivato", + "{name} On": "{name}: attivato", + "{name} opened for editing; it is stacked with the original": "{name} aperta per la modifica; è in pila con l'originale", + "{name} — double-click to reset it": "{name}: doppio clic per reimpostarlo", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Modifiche non scritte sul disco: {n}{0:.0}: {e}\nSe esci ora, andranno perse.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Modifiche salvate in memoria ma non scritte sul disco: {n}{0:.0}: {e}\nLightCraft riprova da solo; se esci ora, andranno perse.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Modifiche salvate solo in memoria: {n}{:.0}: {e}; LightCraft riproverà", + "{n} found · {sel} selected": "Trovate: {n} · selezionate: {sel}", + "{n} photos": "{n} foto", + "{n} photos in {} are hidden by the filters above": "I filtri qui sopra nascondono {n} foto in {}", + "{n} photo{}": "{n} foto{:.0}", + "{n} photo{} match · updates automatically as photos change": "Foto corrispondenti: {n}{:.0} · si aggiorna da solo quando le foto cambiano", + "{n} photo{} now · updates automatically as photos change": "{n} foto{:.0} ora · si aggiorna da solo quando le foto cambiano", + "{pid} (missing)": "{pid} (mancante)", + "{sel_n} selected · {counted}": "Selezionate: {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} foto{:.0}", + "{weekday}, {day}": "{weekday} {day}", + "{weekday}, {day} {month} {year}": "{weekday} {day}/{month:02}/{year}", + "{what} merge added": "Unione {what} aggiunta", + "{what} merge failed: {e}": "Unione {what} non riuscita: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Direttive {{date:…}}: {}", + "{} and higher": "{} e superiori", + "{} contributors · {} commits": "Collaboratori: {} · commit: {}", + "{} failed": "Con errori: {}", + "{} files": "{} file", + "{} files and {f} folder{}": "{} file, cartelle: {f}{:.0}", + "{} item{}": "Elementi: {}{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} di 4 guide: trascina lungo le linee che dovrebbero essere verticali o orizzontali.", + "{} photos": "{} foto", + "{} photos · exposures {} EV": "{} foto · esposizioni {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Foto illeggibili: {}{:.0}", + "{} sample{}": "Campioni: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "Macchie in questa foto: {}", + "{} — show their photos": "{}: mostra le loro foto", + "· {dups} already in the library": "· già nella libreria: {dups}", + "· {n} photo{}": "· {n} foto{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} in Eliminate di recente", + "… and {more} more": "… e altre {more}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft non sa ancora decodificare questa variante raw ({}). Stai modificando l'anteprima JPEG incorporata dalla fotocamera, che contiene già lo stile immagine della fotocamera (ad es. Monocromatico) e il bilanciamento del bianco.", + "Filtered: {label}": "Filtrate: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Foto simili: {n}{:.0} · Visualizza → Cancella filtri per vederle tutte", + "Moved; {} photo(s) relinked": "Spostata; foto ricollegate: {}", + "{} label (click more labels to show any of them)": "Etichetta {} (fai clic su altre etichette per mostrarne una qualsiasi)", + ", {} star{}": ", stelle: {}{:.0}", + ", {} label": ", etichetta {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Scaricato da {host}. Quando è arrivato ed è stato verificato, LightCraft lo installa e {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "In esecuzione sul processore: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Conservate nella cartella “denoise” della libreria; al raggiungimento del limite si eliminano per prime le più vecchie che nessuna foto usa.", + "Denoising {name}: {done} of {total} tiles{}": "Riduzione disturbo di {name}: {done} di {total} riquadri{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} di {} da {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Generazione dell'immagine con il disturbo ridotto… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Addestrato su: {provenance}", + " · {queued} waiting": " · in attesa: {queued}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Mostra le condizioni del modello, poi scarica {} da {host}, lo installa e comincia a usarlo. Non viene inviato nient'altro.", + "In line for AI Denoise ({ahead} ahead)…": "In coda per la riduzione disturbo IA ({ahead} prima)…", + "Running on {}.": "In esecuzione su {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Metti dentro “{parent}”", + "Delete the keyword “{name}”?": "Eliminare la parola chiave “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Viene tolta da {count} foto{:.0}, insieme alle parole chiave sotto di essa. Annulla la riporta.", + "Create Keyword Tag Inside “{name}”…": "Crea tag parola chiave dentro “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ha già “{name}”. Unire le due?", + "There is a “{name}” at the top level already. Merge the two?": "Al livello principale c'è già “{name}”. Unire le due?", + "Added “{keyword}” to {n} photo{}": "Aggiunta “{keyword}” a {n} foto{:.0}", + "On {have} of {of} selected photos": "Su {have} di {of} foto selezionate", + "Remove “{name}”": "Rimuovi “{name}”", + "There is a keyword set “{name}” already": "Esiste già un set di parole chiave “{name}”", + "Added {n} keyword{}": "Parole chiave aggiunte: {n}{:.0}", + "; {n} gained synonyms": "; con nuovi sinonimi: {n}", + "Exported {n} keyword{}": "Parole chiave esportate: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " · Capture One non importa un elenco con ; , < o > in una parola chiave: {names}", + " — left out, as a keyword list can't hold them: {names}": " · escluse, perché un elenco di parole chiave non può contenerle: {names}", + "{done} of {total}": "{done} di {total}", + "{done} of {total} MB": "{done} di {total} MB", + "{pct} %": "{pct}%", + "+{n} more": "+{n} altre", + "1 task is still running: {names}. Quitting stops it.": "1 attività è ancora in corso: {names}. Uscendo si interrompe.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} attività sono ancora in corso: {names}. Uscendo si interrompono.", + "{what} merge cancelled": "Unione {what} annullata", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Foto locali saltate: {n}{0:.0}; gli album contengono foto della libreria (prima aggiungile a Le mie foto)", + "New Folder in “{name}”": "Nuova cartella in “{name}”", + "Moved {} photo(s)": "Foto spostate: {}", + "not added to Apple Photos: {e}": "non aggiunte a Foto di Apple: {e}", + "{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}", + "{n} added to Apple Photos": "Aggiunte a Foto di Apple: {n}", + "{n} photos opened for editing; each is stacked with its original": "{n} foto aperte per la modifica; ciascuna è in pila con il suo originale", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + "{name} opened in the other editor": "{name} opened in the other editor", + "{s} s of AV1 video": "{s} s of AV1 video", + "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", + "{count} more warnings.": "{count} more warnings.", + "· {in_source} identical copies": "· {in_source} identical copies", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "{n} preset{}": "{n} preset{}", + " — kept but not applied: {}": " — kept but not applied: {}", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + "Saved template {name}": "Saved template {name}", + "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", + " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", + "Delete “{name}”?": "Delete “{name}”?", + "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "new, inside {parent}": "new, inside {parent}", + "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", + "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", + "Skipped {problem}": "Skipped {problem}", + "Updated {name}": "Updated {name}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", + "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", + "Slideshow exported: {what}": "Slideshow exported: {what}", + "Exported to {path}": "Exported to {path}", + "…and {more} more": "…and {more} more", + "Create the keyword “{name}”": "Create the keyword “{name}”", + "{n} JPEG files": "{n} JPEG files", + "Exported {} effect preset{}": "Exported {} effect preset{}", + "Imported {name}": "Imported {name}", + "{n} pages": "{n} pages", + " · kept, not applied: {}": " · kept, not applied: {}", + "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "Template preview: {name}": "Template preview: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", + "Applied {name}": "Applied {name}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + "{n} originals opened in the other editor": "{n} originals opened in the other editor", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", + "Effect: {name}": "Effect: {name}", + " in {g} group{}": " in {g} group{}", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages" +} diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json new file mode 100644 index 000000000..382fb80c9 --- /dev/null +++ b/crates/ui-egui/locales/it.json @@ -0,0 +1,1994 @@ +{ + " (library default)": " (predefinito della libreria)", + " (inverted)": " (invertita)", + " · best of its burst": " · la migliore della sua raffica", + " · in a burst": " · in una raffica", + "% of all commits": "% di tutti i commit", + "+ Group": "+ Gruppo", + "+ Rule": "+ Regola", + "0: nothing is rejected.": "0: non viene scartato niente.", + "1 day": "1 giorno", + "2026-04 or 2026-04-12": "2026-04 oppure 2026-04-12", + "= exactly": "= esattamente", + "A blank template keeps the original names.": "Un modello vuoto mantiene i nomi originali.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro nasconde le foto di questa vista: cambialo o toglilo (Visualizza → Cancella filtri)", + "A one-time download of about": "Un download unico di circa", + "AI Mask Model Status": "Stato del modello IA per le maschere", + "About": "Informazioni", + "About LightCraft": "Informazioni su LightCraft", + "Active": "Attivo", + "Activity": "Attività", + "Actual size": "Dimensione reale", + "Add": "Aggiungi", + "Add Catchlight": "Aggiungi riflesso negli occhi", + "Add Folder to Local": "Aggiungi cartella a Locale", + "Add Mask": "Aggiungi maschera", + "Add Object Click": "Aggiungi oggetto con un clic", + "Add Point Color Sample": "Aggiungi campione colore puntuale", + "Add Red Eye Correction": "Aggiungi correzione occhi rossi", + "Add Remove Spot": "Aggiungi rimozione macchie", + "Add Selected Photos": "Aggiungi le foto selezionate", + "Add a rule (⌥-click: a group)": "Aggiungi una regola (⌥-clic: un gruppo)", + "Add in place": "Aggiungi sul posto", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Aggiungi sul posto: la libreria usa i file dove si trovano; niente viene copiato o spostato.", + "Add keyword": "Aggiungi parola chiave", + "Add number": "Aggiungi numero", + "Add to": "Aggiungi a", + "Add to Album": "Aggiungi all'album", + "Add to Favorites": "Aggiungi ai preferiti", + "Add to Local": "Aggiungi a Locale", + "Add to My Photos": "Aggiungi a Le mie foto", + "Add to Selected Photos": "Aggiungi alle foto selezionate", + "Add to Target Album": "Aggiungi all'album di destinazione", + "Add to this mask": "Aggiungi a questa maschera", + "Add/Subtract/Intersect Mask": "Aggiungi, sottrai o interseca maschera", + "Added to photos you import that don't already have it.": "Aggiunto alle foto che importi e che non lo hanno già.", + "Adding": "Aggiunta in corso", + "Adding Lightroom photos…": "Aggiunta delle foto di Lightroom…", + "Advanced search…": "Ricerca avanzata…", + "After": "Dopo", + "After 30 days": "Dopo 30 giorni", + "After 7 days": "Dopo 7 giorni", + "After 90 days": "Dopo 90 giorni", + "After a year": "Dopo un anno", + "Album": "Album", + "Albums": "Album", + "All": "Tutti", + "All Metadata": "Tutti i metadati", + "All Metadata…": "Tutti i metadati…", + "All Photos": "Tutte le foto", + "All rights reserved": "Tutti i diritti riservati", + "All the photos imported from this disk are included.": "Sono incluse tutte le foto importate da questo disco.", + "All types": "Tutti i tipi", + "Alt Text": "Testo alternativo", + "Always": "Sempre", + "Amount": "Quantità", + "Analyzing the photo for AI masks…": "Analisi della foto per le maschere IA…", + "Any Searchable Text": "Qualsiasi testo ricercabile", + "Aperture": "Diaframma", + "Application": "Applicazione", + "Apply": "Applica", + "Apply Color Label Set": "Applica set di etichette colore", + "Apply Filter Preset": "Applica preset di filtro", + "Apply Metadata Preset": "Applica preset di metadati", + "Apply Point Curve Preset": "Applica preset di curva a punti", + "Apply Preset": "Applica preset", + "Apply Settings JSON": "Applica impostazioni JSON", + "Approved": "Approvata", + "Aqua": "Acqua", + "Aqua Hue": "Tonalità acqua", + "Aqua Luminance": "Luminanza acqua", + "Aqua Saturation": "Saturazione acqua", + "Arrange Panel": "Disponi il pannello", + "ArtCraft Website": "Sito web di ArtCraft", + "ArtCraft — more creative apps": "ArtCraft: altre app creative", + "As Shot": "Come scattata", + "As shot": "Come scattata", + "Ascending": "Crescente", + "Aspect": "Proporzioni", + "Aspect Ratio": "Proporzioni", + "Assisted Culling": "Selezione assistita", + "Assisted Culling…": "Selezione assistita…", + "Auto": "Automatico", + "Auto Advance": "Avanzamento automatico", + "Auto Advance Off": "Avanzamento automatico disattivato", + "Auto Advance On": "Avanzamento automatico attivato", + "Auto Advance after rating or flagging": "Avanzamento automatico dopo valutazione o contrassegno", + "Auto Advance: move to the next photo after rating or flagging": "Avanzamento automatico: passa alla foto successiva dopo una valutazione o un contrassegno", + "Auto Align": "Allineamento automatico", + "Auto B&W Mix": "Mix bianco e nero automatico", + "Auto Crop": "Ritaglio automatico", + "Auto Import": "Importazione automatica", + "Auto Import Now": "Importa automaticamente ora", + "Auto Import Settings": "Impostazioni di importazione automatica", + "Auto Mask": "Maschera automatica", + "Auto Select": "Selezione automatica", + "Auto Settings": "Impostazioni automatiche", + "Auto Straighten": "Raddrizzamento automatico", + "Auto Sync": "Sincronizzazione automatica", + "Auto settings applied": "Impostazioni automatiche applicate", + "Auto-Stack by Capture Time": "Raggruppa automaticamente per ora di scatto", + "Auto-Stack by Capture Time…": "Raggruppa automaticamente per ora di scatto…", + "Auto-Tag Photos from Tracklog": "Geolocalizza automaticamente le foto dal tracciato", + "Auto-Tag from Tracklog": "Geolocalizza dal tracciato", + "Auto-Tag from Tracklog…": "Geolocalizza dal tracciato…", + "Automatic": "Automatico", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatico disegna la vista Dettaglio alla dimensione in cui è mostrata, fino ai pixel della foto. Scegli una dimensione per limitarla: più piccola è più veloce.", + "Automatically Write Changes into XMP": "Scrivi automaticamente le modifiche nell'XMP", + "Automatically write changes into XMP sidecars": "Scrivi automaticamente le modifiche nei file XMP collaterali", + "Autumn Gold": "Oro d'autunno", + "B&W": "B/N", + "B&W Mix": "Mix B/N", + "B&W Mixer": "Mixer B/N", + "Back": "Indietro", + "Back Up Library…": "Esegui backup della libreria…", + "Back to Edit": "Torna a Modifica", + "Back to Edit (E)": "Torna a Modifica (E)", + "Back to Grid": "Torna alla griglia", + "Background": "Sfondo", + "Balance": "Bilanciamento", + "Basic": "Base", + "Before": "Prima", + "Before/After (Y)": "Prima/Dopo (Y)", + "Before/After Settings": "Impostazioni Prima/Dopo", + "Before/After Split": "Prima/Dopo diviso", + "Before/After Split Top/Bottom": "Prima/Dopo diviso sopra/sotto", + "Before/After Top/Bottom": "Prima/Dopo sopra/sotto", + "Begin Interaction": "Inizia interazione", + "Best of Similar Shots": "La migliore tra scatti simili", + "Binary assets added": "Risorse binarie aggiunte", + "Binary assets removed": "Risorse binarie rimosse", + "Bit depth": "Profondità bit", + "Black & White": "Bianco e nero", + "Blacks": "Neri", + "Bleach Bypass": "Bleach bypass", + "Bleached": "Sbiancato", + "Blending": "Fusione", + "Blue": "Blu", + "Blue Hour Boost": "Ora blu potenziata", + "Blue Hue": "Tonalità blu", + "Blue Luminance": "Luminanza blu", + "Blue Primary": "Primario blu", + "Blue Saturation": "Saturazione blu", + "Blue channel": "Canale blu", + "Blue label": "Etichetta blu", + "Blue/Yellow Fringe": "Frange blu/gialle", + "Blurry photos below the score are flagged as rejects.": "Le foto mosse sotto il punteggio vengono contrassegnate come scartate.", + "Boundary Warp": "Deformazione dei bordi", + "Bright & Airy": "Luminoso e arioso", + "Bring All to Front": "Porta tutto in primo piano", + "Browse Folder": "Sfoglia cartella", + "Browse Folder…": "Sfoglia cartella…", + "Browse Profiles": "Sfoglia profili", + "Browse…": "Sfoglia…", + "Brush": "Pennello", + "Brush Settings": "Impostazioni pennello", + "Brush Stroke": "Tratto pennello", + "Build Previews": "Genera anteprime", + "Build Smart Previews": "Genera anteprime avanzate", + "By Date": "Per data", + "By day (YYYY/YYYY-MM-DD)": "Per giorno (AAAA/AAAA-MM-GG)", + "By month (YYYY/YYYY-MM)": "Per mese (AAAA/AAAA-MM)", + "CPU threads": "Thread della CPU", + "Calibration": "Calibrazione", + "Camera": "Fotocamera", + "Camera make and model": "Marca e modello della fotocamera", + "Cameras and Cards": "Fotocamere e schede", + "Cancel": "Annulla", + "Cancel AI Mask Model Download": "Annulla il download del modello IA per le maschere", + "Cancel Download": "Annulla download", + "Cancel Interaction": "Annulla interazione", + "Cancel Preview Build": "Annulla generazione anteprime", + "Candidate": "Candidata", + "Caption": "Didascalia", + "Capture Date": "Data di scatto", + "Capture date and time in your own format (directives below)": "Data e ora di scatto nel formato che preferisci (direttive qui sotto)", + "Capture date as YYYYMMDD": "Data di scatto come AAAAMMGG", + "Captured": "Scattata", + "Catalog Statistics": "Statistiche del catalogo", + "Catchlight": "Riflesso negli occhi", + "Change": "Cambia", + "Change Component Mode": "Cambia modalità del componente", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "I caratteri non ammessi nei nomi dei file (/ \\ : * ? \" < > |) diventano -; a un nome già esistente si aggiunge -1, -2….", + "Check All": "Seleziona tutto", + "Check Output Path": "Controlla il percorso di uscita", + "Check the files of this type": "Seleziona i file di questo tipo", + "Checking names…": "Controllo dei nomi…", + "Choose Another Library…": "Scegli un'altra libreria…", + "Choose Edit Settings to Copy": "Scegli le impostazioni di modifica da copiare", + "Choose Edit Settings to Copy…": "Scegli le impostazioni di modifica da copiare…", + "Choose Folder…": "Scegli cartella…", + "Choose a photo in the grid or filmstrip": "Scegli una foto nella griglia o nella striscia", + "Choose…": "Scegli…", + "Cinematic": "Cinematografico", + "City": "Città", + "Clarity": "Chiarezza", + "Clean Studio": "Studio pulito", + "Clear": "Cancella", + "Clear Cache": "Svuota cache", + "Clear Filters": "Cancella filtri", + "Clear Guides": "Cancella guide", + "Clear History": "Cancella cronologia", + "Clear Preview Cache": "Svuota cache delle anteprime", + "Clear Quick Collection": "Svuota la raccolta rapida", + "Clear Reference": "Cancella riferimento", + "Clear all": "Cancella tutto", + "Clear form": "Svuota il modulo", + "Clear rating": "Togli valutazione", + "Clear rating and advance": "Togli valutazione e avanza", + "Cleared History": "Cronologia cancellata", + "Click Zoom Ratio": "Rapporto di zoom al clic", + "Click a shortcut, then press the new keys (Esc cancels).": "Fai clic su una scorciatoia, poi premi i nuovi tasti (Esc annulla).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Fai clic su un tag per inserirlo. Gli esempi si riferiscono a IMG_0042.CR3 scattata il 14 gen 2026 alle 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Fai clic sulle foto nella griglia per aggiungere o togliere la parola chiave; Esc interrompe", + "Click the object to select it · ⌥-click leaves a part out": "Fai clic sull'oggetto per selezionarlo · ⌥-clic esclude una parte", + "Click the photo to pick a colour · ⇧-click adds more": "Fai clic sulla foto per scegliere un colore · ⇧-clic ne aggiunge altri", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Fai clic sulla foto per scegliere un colore; ⇧-clic ne aggiunge altri (fino a 5)", + "Click to clear these filters": "Fai clic per togliere questi filtri", + "Click to restore · double-click to rename": "Clic per ripristinare · doppio clic per rinominare", + "Clone": "Clona", + "Close": "Chiudi", + "Close Panel": "Chiudi pannello", + "Close Window": "Chiudi finestra", + "Close all": "Chiudi tutto", + "Close file": "Chiudi file", + "Close panel": "Chiudi pannello", + "Cloudy": "Nuvoloso", + "Collapse": "Comprimi", + "Collapse All Stacks": "Comprimi tutte le pile", + "Collapse Stack": "Comprimi pila", + "Color": "Colore", + "Color Grading": "Gradazione colore", + "Color Label": "Etichetta colore", + "Color Label Names": "Nomi delle etichette colore", + "Color Label Sets": "Set di etichette colore", + "Color Mixer": "Miscelatore colore", + "Color Noise Reduction": "Riduzione disturbo colore", + "Color Priority": "Priorità colore", + "Color Range": "Intervallo colori", + "Color label cleared": "Etichetta colore tolta", + "Color space": "Spazio colore", + "Color space: sRGB (AVIF)": "Spazio colore: sRGB (AVIF)", + "Colors": "Colori", + "Combine files…": "Combina file…", + "Command Journal": "Registro dei comandi", + "Comments panel": "Pannello commenti", + "Commits": "Commit", + "Company": "Azienda", + "Compare": "Confronta", + "Compare (Shift+C)": "Confronta (Maiusc+C)", + "Compare Before and After": "Confronta prima e dopo", + "Compare needs at least two photos": "Per confrontare servono almeno due foto", + "Component options": "Opzioni del componente", + "Compression": "Compressione", + "Concrete Cool": "Cemento freddo", + "Confirm before deleting": "Chiedi conferma prima di eliminare", + "Confirm before moving photos to Recently Deleted": "Chiedi conferma prima di spostare le foto in Eliminate di recente", + "Constrain Crop": "Vincola il ritaglio", + "Continue": "Continua", + "Continue Without Saving": "Continua senza salvare", + "Contrast": "Contrasto", + "Contributors": "Collaboratori", + "Convert to DNG": "Converti in DNG", + "Cool Fade": "Dissolvenza fredda", + "Cool Morning": "Mattino fresco", + "Copy": "Copia", + "Copy After's Settings to Before": "Copia le impostazioni di Dopo in Prima", + "Copy Before to After": "Copia Prima in Dopo", + "Copy Before's Settings to After": "Copia le impostazioni di Prima in Dopo", + "Copy Edit Settings": "Copia impostazioni di modifica", + "Copy History Step Settings to Before": "Copia le impostazioni del passaggio in Prima", + "Copy Metadata": "Copia metadati", + "Copy as DNG": "Copia come DNG", + "Copy into the library (else use the files where they are)": "Copia nella libreria (altrimenti usa i file dove si trovano)", + "Copy pages": "Copia pagine", + "Copy the files (into the library's Originals/, or a folder you choose)": "Copia i file (negli Originals/ della libreria, o in una cartella a tua scelta)", + "Copy to": "Copia in", + "Copy to Clipboard": "Copia negli appunti", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Copia: i file vengono copiati nella cartella qui sotto e la libreria usa le copie; gli originali restano come sono.", + "Copyright": "Copyright", + "Copyright Info URL": "URL informazioni copyright", + "Copyright Status": "Stato del copyright", + "Country": "Paese", + "Create Album": "Crea album", + "Create Album…": "Crea album…", + "Create Folder": "Crea cartella", + "Create Folder…": "Crea cartella…", + "Create New Mask": "Crea nuova maschera", + "Create PDF from clipboard": "Crea PDF dagli appunti", + "Create PDF from file…": "Crea PDF da file…", + "Create PDF from images…": "Crea PDF da immagini…", + "Create Preset": "Crea preset", + "Create Preset…": "Crea preset…", + "Create Proof Copy": "Crea copia di prova", + "Create Smart Album": "Crea album smart", + "Create Smart Album from Filter…": "Crea album smart dal filtro…", + "Create Smart Album…": "Crea album smart…", + "Create Stack": "Crea pila", + "Create Version": "Crea versione", + "Create Version from Step": "Crea versione dal passaggio", + "Create Virtual Copy": "Crea copia virtuale", + "Creator": "Autore", + "Creator metadata": "Metadati dell'autore", + "Crisp Landscape": "Paesaggio nitido", + "Crisp Vista": "Panorama nitido", + "Crop": "Ritaglia", + "Crop & Rotate": "Ritaglia e ruota", + "Crop & Rotate (C)": "Ritaglia e ruota (C)", + "Crop Aspect": "Proporzioni del ritaglio", + "Crop, Rotate, Geometry": "Ritaglio, rotazione, geometria", + "Cross Process": "Processo incrociato", + "Culling": "Selezione", + "Curve": "Curva", + "Custom": "Personalizzato", + "Custom template…": "Modello personalizzato…", + "Custom…": "Personalizzato…", + "Cut": "Taglia", + "Cut pages": "Taglia pagine", + "Cycle Crop Overlay": "Cambia sovrapposizione del ritaglio", + "Cycle Crop Overlay Orientation": "Cambia orientamento della sovrapposizione del ritaglio", + "Cycle Info Overlay": "Cambia sovrapposizione delle informazioni", + "Cycle Mask Overlay Color": "Cambia colore della sovrapposizione maschera", + "Cycle Mask Overlay Mode": "Cambia modalità della sovrapposizione maschera", + "Cylindrical": "Cilindrica", + "Dark & Moody": "Scuro e intenso", + "Darken": "Scurisci", + "Darks": "Toni scuri", + "Date": "Data", + "Date Groups": "Gruppi per data", + "Day": "Giorno", + "Daylight": "Luce diurna", + "Decrease Brush Feather": "Riduci sfumatura del pennello", + "Decrease Brush Size": "Riduci dimensione del pennello", + "Deep Sky": "Cielo profondo", + "Defringe": "Elimina frange", + "Deghost Amount": "Quantità di rimozione fantasmi", + "Dehaze": "Elimina foschia", + "Delete": "Elimina", + "Delete (⌫)": "Elimina (⌫)", + "Delete Album": "Elimina album", + "Delete All Masks": "Elimina tutte le maschere", + "Delete Color Label Set": "Elimina set di etichette colore", + "Delete Component": "Elimina componente", + "Delete Export Preset": "Elimina preset di esportazione", + "Delete Filter Preset": "Elimina preset di filtro", + "Delete Imported Profile": "Elimina profilo importato", + "Delete Keyword": "Elimina parola chiave", + "Delete Keyword Set": "Elimina set di parole chiave", + "Delete Mask": "Elimina maschera", + "Delete Metadata Preset": "Elimina preset di metadati", + "Delete Permanently": "Elimina definitivamente", + "Delete Photo": "Elimina foto", + "Delete Photos": "Elimina foto", + "Delete Point Color": "Elimina colore puntuale", + "Delete Point Color Sample": "Elimina campione colore puntuale", + "Delete Point Curve Preset": "Elimina preset di curva a punti", + "Delete Preset": "Elimina preset", + "Delete Red Eye": "Elimina occhi rossi", + "Delete Red Eye Correction": "Elimina correzione occhi rossi", + "Delete Spot": "Elimina macchia", + "Delete Version": "Elimina versione", + "Delete all": "Elimina tutto", + "Delete all spots": "Elimina tutte le macchie", + "Delete mask": "Elimina maschera", + "Delete them": "Eliminale", + "Density": "Densità", + "Depth Range": "Intervallo di profondità", + "Descending": "Decrescente", + "Describe": "Descrivi", + "Describe what to keep": "Descrivi cosa tenere", + "Describe what to leave out": "Descrivi cosa escludere", + "Describe what to select": "Descrivi cosa selezionare", + "Describe…": "Descrivi…", + "Deselect": "Deseleziona", + "Deselect All": "Deseleziona tutto", + "Desert Heat": "Calore del deserto", + "Desert Warmth": "Tepore del deserto", + "Desktop": "Scrivania", + "Detail": "Dettaglio", + "Detail (D)": "Dettaglio (D)", + "Details": "Dettagli", + "Diagonal": "Diagonale", + "Discard Preview Cache": "Elimina cache delle anteprime", + "Display P3": "Display P3", + "Display name": "Nome visualizzato", + "Display theme": "Tema di visualizzazione", + "Distortion": "Distorsione", + "Document properties…": "Proprietà documento…", + "Don't enlarge": "Non ingrandire", + "Done": "Fine", + "Download": "Scarica", + "Download AI Mask Model": "Scarica il modello IA per le maschere", + "Download the SAM 3 Model?": "Scaricare il modello SAM 3?", + "Downloads": "Download", + "Drag along the horizon; double-click for Auto": "Trascina lungo l'orizzonte; doppio clic per Automatico", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Trascina sopra un occhio nella foto; la pupilla all'interno viene trovata da sola.", + "Draw Guides": "Disegna guide", + "Duplicate": "Duplica", + "Duplicate Component": "Duplica componente", + "Duplicate Mask": "Duplica maschera", + "Duplicate and Invert Mask": "Duplica e inverti maschera", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Ogni / apre un livello di cartella; i tag vengono compilati per ogni foto (un livello senza metadati diventa \"sconosciuto\").", + "Edge": "Bordo", + "Edit": "Modifica", + "Edit (E)": "Modifica (E)", + "Edit Capture Time": "Modifica ora di scatto", + "Edit Capture Time…": "Modifica ora di scatto…", + "Edit Color Label Names": "Modifica nomi delle etichette colore", + "Edit Color Label Names…": "Modifica nomi delle etichette colore…", + "Edit Copy for External Editor": "Modifica copia nell'editor esterno", + "Edit Date": "Modifica data", + "Edit Info": "Modifica informazioni", + "Edit Label Names": "Modifica nomi delle etichette", + "Edit Label Names…": "Modifica nomi delle etichette…", + "Edit Mask": "Modifica maschera", + "Edit Mask Component": "Modifica componente della maschera", + "Edit Sections": "Modifica sezioni", + "Edit Smart Album": "Modifica album smart", + "Edit Smart Album…": "Modifica album smart…", + "Edit Spot": "Modifica macchia", + "Edit in External Editor": "Modifica nell'editor esterno", + "Edit settings copied": "Impostazioni di modifica copiate", + "Edited": "Modificata", + "Edited or not": "Modificata o no", + "Editing": "Modifica", + "Effect": "Effetto", + "Effects": "Effetti", + "Empty Recently Deleted": "Svuota Eliminate di recente", + "Enable Lens Corrections": "Attiva correzioni obiettivo", + "Enable Profile Corrections": "Attiva correzioni del profilo", + "End Interaction": "Termina interazione", + "English": "Inglese", + "Enter Full Screen": "Schermo intero", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Inserisci una data come AAAA-MM-GG HH:MM:SS", + "Erase": "Cancella", + "Every EXIF, GPS and XMP field in the file": "Ogni campo EXIF, GPS e XMP del file", + "Existing previews": "Anteprime esistenti", + "Exit": "Esci", + "Expand": "Espandi", + "Expand All Stacks": "Espandi tutte le pile", + "Expand Stack": "Espandi pila", + "Expand/Collapse Stack": "Espandi/comprimi pila", + "Export": "Esporta", + "Export Album with Preset": "Esporta album con preset", + "Export Album…": "Esporta album…", + "Export As…": "Esporta come…", + "Export Group…": "Esporta gruppo…", + "Export Now": "Esporta ora", + "Export Point Curve Presets": "Esporta preset di curva a punti", + "Export Point Curve Presets…": "Esporta preset di curva a punti…", + "Export Presets": "Esporta predefiniti", + "Export Presets…": "Esporta preset…", + "Export User Presets…": "Esporta preset utente…", + "Export stopped unexpectedly": "L'esportazione si è interrotta in modo imprevisto", + "Export with Preset": "Esporta con preset", + "Export with Previous": "Esporta con le impostazioni precedenti", + "Exporting": "Esportazione in corso", + "Export…": "Esporta…", + "Exposure": "Tempo di esposizione", + "Exposure (shutter · aperture · ISO)": "Esposizione (tempo · diaframma · ISO)", + "Extended Description": "Descrizione estesa", + "External Editor": "Editor esterno", + "Face Boxes": "Riquadri dei volti", + "Face boxes": "Riquadri dei volti", + "Face names written to XMP by Lightroom and other apps show up here.": "Qui compaiono i nomi dei volti scritti nell'XMP da Lightroom e da altre app.", + "Faded Matte": "Opaco sbiadito", + "Faded Slide": "Diapositiva sbiadita", + "Faded Urban": "Città sbiadita", + "Favorite Preset": "Preset preferito", + "Favorite Profile": "Profilo preferito", + "Favorites": "Preferiti", + "Feather": "Sfumatura", + "File": "File", + "File & date": "File e data", + "File Format": "Formato file", + "File Name": "Nome file", + "File Path": "Percorso del file", + "File Size": "Dimensione del file", + "File Type": "Tipo di file", + "File name": "Nome file", + "File types:": "Tipi di file:", + "Filename": "Nome file", + "Fill": "Riempi", + "Fill Edges": "Riempi i bordi", + "Film": "Pellicola", + "Filmstrip": "Striscia", + "Filmstrip (/)": "Striscia (/)", + "Filter": "Filtro", + "Filter Bar": "Barra dei filtri", + "Filter Presets": "Preset di filtro", + "Filter bar": "Barra dei filtri", + "Filter fields": "Campi del filtro", + "Find Dust Spots": "Trova macchie di polvere", + "Find Missing Photos": "Trova foto mancanti", + "Find Missing Photos…": "Trova foto mancanti…", + "Find Similar Photos": "Trova foto simili", + "Find tools and commands…": "Trova strumenti e comandi…", + "Find…": "Trova…", + "First": "Primo", + "First commit": "Primo commit", + "Fit": "Adatta", + "Fit (F)": "Adatta (F)", + "Fit on Screen": "Adatta allo schermo", + "Fit to width": "Adatta alla larghezza", + "Fit visible": "Adatta all'area visibile", + "Five stars": "Cinque stelle", + "Fixed keys": "Tasti fissi", + "Flag": "Bandiera", + "Flag as Pick": "Contrassegna come scelta", + "Flag as Reject": "Contrassegna come scartata", + "Flagged as Pick": "Contrassegnata come scelta", + "Flagged as Reject": "Contrassegnata come scartata", + "Flash": "Flash", + "Flip H": "Capovolgi O", + "Flip Horizontal": "Capovolgi orizzontalmente", + "Flip Overlay (⇧O)": "Capovolgi sovrapposizione (⇧O)", + "Flip V": "Capovolgi V", + "Flip Vertical": "Capovolgi verticalmente", + "Float panel": "Rendi pannello flottante", + "Flow": "Flusso", + "Fluorescent": "Fluorescente", + "Focal Length": "Lunghezza focale", + "Focal length": "Lunghezza focale", + "Focus (assisted culling)": "Messa a fuoco (selezione assistita)", + "Folder": "Cartella", + "Folder name (renamed on disk; its photos follow)": "Nome della cartella (rinominata sul disco; le sue foto la seguono)", + "Folders": "Cartelle", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Le cartelle e {date} usano l'ora di scatto; una foto che non ce l'ha usa la data di oggi.", + "Food": "Cibo", + "Forget Unchanged Local Photos": "Dimentica le foto locali non modificate", + "Forget unchanged photos": "Dimentica le foto non modificate", + "Form fields panel": "Pannello campi modulo", + "Format": "Formato", + "Forward": "Avanti", + "Frame time": "Tempo per fotogramma", + "Frame update": "Aggiornamento del fotogramma", + "Free": "Libero", + "Fresh & Bright": "Fresco e luminoso", + "Friday": "Venerdì", + "Full": "Completo", + "Full Screen Preview": "Anteprima a schermo intero", + "Lights Out": "Luci spente", + "Full screen mode": "Modalità schermo intero", + "Full size": "Dimensione piena", + "GPS Track Log": "Tracciato GPS", + "GPU Rendering": "Rendering con GPU", + "GPU rendering": "Rendering con GPU", + "General": "Generale", + "Generated demo photo": "Foto dimostrativa generata", + "Geometry": "Geometria", + "Get Develop Settings": "Ottieni impostazioni di sviluppo", + "Global Hue": "Tonalità globale", + "Global Luminance": "Luminanza globale", + "Global Saturation": "Saturazione globale", + "Glossy": "Lucido", + "Go to History Step": "Vai al passaggio della cronologia", + "Golden": "Dorato", + "Golden Glow": "Bagliore dorato", + "Golden Hour": "Ora d'oro", + "Grab bag": "Varie", + "Grain": "Grana", + "Graphic": "Grafica", + "Green": "Verde", + "Green Amount": "Quantità verde", + "Green Hue": "Tonalità verde", + "Green Hue High": "Tonalità verde alta", + "Green Hue Low": "Tonalità verde bassa", + "Green Luminance": "Luminanza verde", + "Green Primary": "Primario verde", + "Green Saturation": "Saturazione verde", + "Green channel": "Canale verde", + "Green label": "Etichetta verde", + "Grid": "Griglia", + "Grid Info": "Informazioni della griglia", + "Gritty Street": "Strada grintosa", + "Group": "Gruppo", + "Group by Date: Automatic": "Raggruppa per data: automatico", + "Group by Day": "Raggruppa per giorno", + "Group by Month": "Raggruppa per mese", + "Group by Year": "Raggruppa per anno", + "Group by date": "Raggruppa per data", + "Group into Stack": "Raggruppa in una pila", + "Group name": "Nome gruppo", + "Guided": "Guidato", + "Guided Upright": "Raddrizzamento guidato", + "Guided Upright Guides": "Guide del raddrizzamento guidato", + "HDR Merge": "Unione HDR", + "HDR Merge Preview": "Anteprima unione HDR", + "HDR Panorama Merge": "Unione panorama HDR", + "HDR Panorama Merge Preview": "Anteprima unione panorama HDR", + "HDR Panorama with Last Settings": "Panorama HDR con le ultime impostazioni", + "HDR Panoramas": "Panorami HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panorami HDR", + "HDR with Last Settings": "HDR con le ultime impostazioni", + "HDR…": "HDR…", + "Has Edits": "Con modifiche", + "Has GPS": "Con GPS", + "Heal": "Correggi", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Correggi le piccole macchie scure e sfumate che la polvere del sensore lascia sulle zone uniformi", + "Height": "Altezza", + "Height (px)": "Altezza (px)", + "Help": "Aiuto", + "Hide LightCraft": "Nascondi LightCraft", + "Hide Mask": "Nascondi maschera", + "Hide Others": "Nascondi altri", + "Hides this shortcut only; the folder and its photos stay as they are": "Nasconde solo questa scorciatoia; la cartella e le sue foto restano come sono", + "High": "Alta", + "High Contrast B&W": "B/N ad alto contrasto", + "Highlight": "Luci", + "Highlights": "Luci", + "Highlights Hue": "Tonalità delle luci", + "Highlights Luminance": "Luminanza delle luci", + "Highlights Saturation": "Saturazione delle luci", + "Highlights split": "Divisione delle luci", + "Histogram": "Istogramma", + "History": "Cronologia", + "History & Activity (Y)": "Cronologia e attività (Y)", + "Home": "Cartella personale", + "Horizontal": "Orizzontale", + "Horizontal inset (% of short edge)": "Margine orizzontale (% del lato corto)", + "Horizontal text": "Testo orizzontale", + "Hours": "Ore", + "How to install the model": "Come installare il modello", + "Hue": "Tonalità", + "Hue Range": "Intervallo di tonalità", + "ISO Speed": "Sensibilità ISO", + "ISO speed": "Sensibilità ISO", + "If file exists": "Se il file esiste", + "Image": "Immagine", + "Import": "Importa", + "Import Date": "Data di importazione", + "Import Lightroom Catalog": "Importa catalogo Lightroom", + "Import Lightroom Catalog…": "Importa catalogo Lightroom…", + "Import Photos": "Importa foto", + "Import Photos…": "Importa foto…", + "Import Point Curve Presets": "Importa preset di curva a punti", + "Import Point Curve Presets…": "Importa preset di curva a punti…", + "Import Presets": "Importa predefiniti", + "Import Presets…": "Importa preset…", + "Import Profiles": "Importa profili", + "Import Profiles & Presets…": "Importa profili e preset…", + "Import as new": "Importa come nuova", + "Import from Device": "Importa da dispositivo", + "Import from Folder…": "Importa da cartella…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importa le foto con File → Importa foto… (Cmd+Maiusc+I), oppure trascinale qui", + "Imported": "Importato", + "Imported Photos": "Foto importate", + "Importing": "Importazione in corso", + "Importing Lightroom Catalog": "Importazione del catalogo Lightroom", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "L'importazione cerca le foto nell'origine; non aggiunge la cartella a Locale (usa Locale → Sfoglia cartella… per lavorare in una cartella senza importare).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "In Griglia foto e Griglia quadrata: contrassegna come scelta e avanza", + "In Photo Grid and Square Grid: expand or collapse the stack": "In Griglia foto e Griglia quadrata: espandi o comprimi la pila", + "In Recently Deleted": "In Eliminate di recente", + "In library": "Nella libreria", + "In memory — nothing is saved": "In memoria: non viene salvato niente", + "In use": "In uso", + "In-memory session: these settings last until LightCraft quits.": "Sessione in memoria: queste impostazioni durano finché LightCraft resta aperto.", + "Include subfolders": "Includi sottocartelle", + "Increase Brush Feather": "Aumenta sfumatura del pennello", + "Increase Brush Size": "Aumenta dimensione del pennello", + "Info": "Informazioni", + "Info (I)": "Informazioni (I)", + "Info Overlay Off": "Sovrapposizione informazioni disattivata", + "Info Overlay: Exposure": "Sovrapposizione informazioni: esposizione", + "Info Overlay: File": "Sovrapposizione informazioni: file", + "Info overlay": "Sovrapposizione informazioni", + "Insert at the cursor": "Inserisci al cursore", + "Inspect Lightroom Catalog": "Esamina catalogo Lightroom", + "Inspect Photo": "Esamina foto", + "Inspecting Lightroom Catalog": "Analisi del catalogo Lightroom", + "Install the SAM 3 Model": "Installa il modello SAM 3", + "Instant ’70s": "Istantanea anni '70", + "Interface": "Interfaccia", + "Interface language": "Lingua dell'interfaccia", + "Intersect": "Intersezione", + "Intersect with": "Interseca con", + "Into one folder": "In un'unica cartella", + "Invert": "Inverti", + "Invert Component": "Inverti componente", + "Invert Mask": "Inverti maschera", + "JPEG (Large)": "JPEG (grande)", + "JPEG (Small)": "JPEG (piccolo)", + "Join the ArtCraft Discord": "Unisciti al Discord di ArtCraft", + "Join the ArtCraft Discord…": "Unisciti al Discord di ArtCraft…", + "Join the ArtCraft community on Discord": "Unisciti alla community di ArtCraft su Discord", + "Keep in Local": "Tieni in Locale", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Tienila su un disco con spazio libero: le anteprime avanzate occupano circa 1 MB per foto. La cache delle miniature resta nella libreria.", + "Keyboard Shortcuts": "Scorciatoie da tastiera", + "Keyboard shortcuts": "Scorciatoie da tastiera", + "Keyword": "Parola chiave", + "Keyword Painter": "Pennello parole chiave", + "Keyword Set": "Set di parole chiave", + "Keyword Sets": "Set di parole chiave", + "Keyword Suggestions": "Suggerimenti di parole chiave", + "Keyword to paint": "Parola chiave da applicare", + "Keywords": "Parole chiave", + "Keywords (K)": "Parole chiave (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Le parole chiave che aggiungi compaiono qui; ⌥1-⌥9 le applicano.", + "Label": "Etichetta", + "Landscape": "Paesaggio", + "Language": "Lingua", + "Last": "Ultimo", + "Last GPU fallback": "Ultimo ripiego dalla GPU", + "Last commit": "Ultimo commit", + "Last loupe render": "Ultimo rendering della lente", + "Last view": "Ultima vista", + "Lateral Chromatic Aberration": "Aberrazione cromatica laterale", + "Layer": "Livello", + "Layers": "Livelli", + "Leave them": "Lasciale", + "Lens": "Obiettivo", + "Lens Blur": "Sfocatura obiettivo", + "Lens Corrections": "Correzioni obiettivo", + "Level": "Livello", + "Library": "Libreria", + "Library Info": "Informazioni sulla libreria", + "Library Originals": "Originali della libreria", + "Library Preferences": "Preferenze della libreria", + "Library State": "Stato della libreria", + "Library saved": "Libreria salvata", + "Licence:": "Licenza:", + "Light": "Luce", + "LightCraft Default": "Predefinito di LightCraft", + "LightCraft Help": "Aiuto di LightCraft", + "LightCraft Preset": "Preset di LightCraft", + "LightCraft Presets": "Preset di LightCraft", + "LightCraft Website": "Sito web di LightCraft", + "LightCraft could not start": "Impossibile avviare LightCraft", + "LightCraft couldn't find where to keep your library.": "LightCraft non ha trovato dove salvare la tua libreria.", + "LightCraft couldn't open the library at": "LightCraft non è riuscito ad aprire la libreria in", + "LightCraft on GitHub": "LightCraft su GitHub", + "LightCraft website": "Sito web di LightCraft", + "LightCraft — Second Window": "LightCraft: seconda finestra", + "Lightroom Classic Catalog": "Catalogo di Lightroom Classic", + "Lightroom import cancelled": "Importazione da Lightroom annullata", + "Lightroom inspection cancelled": "Analisi di Lightroom annullata", + "Lights": "Toni chiari", + "Limit file size (KB, 0 = off)": "Limita dimensione del file (KB, 0 = nessun limite)", + "Line delta": "Differenza righe", + "Linear": "Lineare", + "Linear Gradient": "Sfumatura lineare", + "Lines +/−": "Righe +/−", + "Lines added": "Righe aggiunte", + "Lines deleted": "Righe eliminate", + "List Albums": "Elenca album", + "List Develop Controls": "Elenca controlli di sviluppo", + "List History": "Elenca cronologia", + "List Presets": "Elenca preset", + "List Profiles": "Elenca profili", + "List this folder in Local from now on": "Mostra d'ora in poi questa cartella in Locale", + "Loading preview…": "Caricamento anteprima…", + "Local": "Locale", + "Local folders": "Cartelle locali", + "Local library — no cloud account needed": "Libreria locale: non serve un account cloud", + "Locate Missing File…": "Trova file mancante…", + "Locate Photo": "Trova foto", + "Location": "Posizione", + "Lock": "Blocca", + "Lock Crop Aspect": "Blocca proporzioni del ritaglio", + "Locked": "Bloccato", + "Long Edge": "Lato lungo", + "Looking for photos…": "Ricerca delle foto…", + "Lossless": "Senza perdita", + "Low": "Bassa", + "Luminance": "Luminanza", + "Luminance Range": "Intervallo di luminanza", + "Lush Greens": "Verdi rigogliosi", + "Magenta": "Magenta", + "Magenta Hue": "Tonalità magenta", + "Magenta Luminance": "Luminanza magenta", + "Magenta Saturation": "Saturazione magenta", + "Make Candidate the Select": "Rendi la candidata quella scelta", + "Manual": "Manuale", + "Marquee zoom": "Ingrandimento selettivo", + "Mask Adjustment": "Regolazione della maschera", + "Masking": "Mascheratura", + "Masking (M)": "Maschere (M)", + "Masks": "Maschere", + "Match": "Corrispondenza", + "Match Total Exposures": "Uniforma le esposizioni totali", + "Matte": "Opaca", + "Matte Soft": "Opaco morbido", + "Med": "Medio", + "Medium Contrast": "Contrasto medio", + "Megapixels": "Megapixel", + "Memory Budget": "Memoria disponibile", + "Memory Usage": "Uso della memoria", + "Memory budget": "Memoria disponibile", + "Memory for caches": "Memoria per le cache", + "Menu": "Menu", + "Merge": "Unisci", + "Merge Keywords": "Unisci parole chiave", + "Merge into…": "Unisci in…", + "Merged": "Unita", + "Merged PRs": "PR unite", + "Merging…": "Unione in corso…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Sono condizioni di Meta, non di LightCraft. Scaricandolo le accetti.", + "Metadata": "Metadati", + "Metadata Preset": "Preset di metadati", + "Metadata Presets": "Preset di metadati", + "Metadata preset": "Preset di metadati", + "Midpoint": "Punto medio", + "Midtones": "Mezzi toni", + "Midtones Hue": "Tonalità dei mezzitoni", + "Midtones Luminance": "Luminanza dei mezzitoni", + "Midtones Saturation": "Saturazione dei mezzitoni", + "Midtones split": "Divisione dei mezzitoni", + "Minimize": "Riduci a icona", + "Missing Photos": "Foto mancanti", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "I metadati mancanti ({camera}, {title}…) lasciano uno spazio vuoto; un nome che risulta vuoto mantiene il nome originale.", + "Misty Morning": "Mattino nebbioso", + "Mode": "Metodo", + "Model": "Modello", + "Models": "Modelli", + "Modified Date": "Data di modifica", + "Monday": "Lunedì", + "Mono Red Filter": "Mono con filtro rosso", + "Mono Rich": "Mono ricco", + "Mono Soft": "Mono morbido", + "Monochrome": "Monocromia", + "Month": "Mese", + "Moody": "Cupo", + "Moody Portrait": "Ritratto cupo", + "More": "Altro", + "More preset options": "Altre opzioni del preset", + "Move": "Sposta", + "Move Album": "Sposta album", + "Move Down": "Sposta giù", + "Move Down in Stack": "Sposta giù nella pila", + "Move Folder": "Sposta cartella", + "Move Folder To…": "Sposta cartella in…", + "Move Mask": "Sposta maschera", + "Move Preset to Group": "Sposta preset nel gruppo", + "Move Preset to New Group": "Sposta preset in un nuovo gruppo", + "Move Up": "Sposta su", + "Move Up in Stack": "Sposta su nella pila", + "Move in Stack": "Sposta nella pila", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Sposta i file (negli Originals/ della libreria, o in una cartella a tua scelta), togliendoli dall'origine", + "Move them": "Spostale", + "Move to": "Sposta in", + "Move to Group": "Sposta nel gruppo", + "Move to group": "Sposta nel gruppo", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Sposta: i file vengono spostati nella cartella qui sotto; ogni originale (con il suo XMP collaterale) viene tolto dall'origine solo dopo aver verificato la copia. I duplicati e i file con errori restano dove sono.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Sposta in Eliminate di recente ogni foto importata da questo disco; nessun file viene toccato", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Sposta in Eliminate di recente le foto importate da questa cartella; nessun file viene toccato", + "Muted": "Attenuato", + "Muted Film": "Pellicola attenuata", + "My Photos": "Le mie foto", + "My Photos Panel": "Pannello Le mie foto", + "Name": "Nome", + "Name (A–Z)": "Nome (A-Z)", + "Name of the folder the original is in": "Nome della cartella in cui si trova l'originale", + "Named": "Con nome", + "Named People": "Persone con nome", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "I nomi compaiono nel menu delle etichette, nella barra dei filtri e nel pannello Informazioni, e vengono scritti nell'XMP. Vuoto = il nome del colore.", + "Navigator": "Navigatore", + "Needs Work": "Da rivedere", + "Neon Dusk": "Tramonto al neon", + "Neon Night": "Notte al neon", + "Nested group": "Gruppo annidato", + "Neutral": "Neutro", + "Never": "Mai", + "New": "Nuovo", + "New Album": "Nuovo album", + "New Album…": "Nuovo album…", + "New Folder…": "Nuova cartella…", + "New Group…": "Nuovo gruppo…", + "New Smart Album": "Nuovo album smart", + "New Smart Album from Filter…": "Nuovo album smart dal filtro…", + "New Smart Album…": "Nuovo album smart…", + "New album": "Nuovo album", + "New album…": "Nuovo album…", + "New blank PDF": "Nuovo PDF vuoto", + "New name": "Nuovo nome", + "New time": "Nuova ora", + "New…": "Nuovo…", + "Next Photo": "Foto successiva", + "Night Blue": "Blu notte", + "No": "No", + "No Camera or Card Found": "Nessuna fotocamera o scheda trovata", + "No Date Groups": "Nessun raggruppamento per data", + "No Stars": "Nessuna stella", + "No Tool": "Nessuno strumento", + "No automatic versions.": "Nessuna versione automatica.", + "No camera": "Nessuna fotocamera", + "No contributor data was built into this copy.": "Questa copia non include dati sui collaboratori.", + "No date": "Nessuna data", + "No dust spots found": "Nessuna macchia di polvere trovata", + "No edits yet.": "Ancora nessuna modifica.", + "No exposure information": "Nessuna informazione di esposizione", + "No filter presets yet": "Ancora nessun preset di filtro", + "No lens data embedded in this file (DNG lens opcodes only).": "In questo file non ci sono dati dell'obiettivo (solo opcode obiettivo DNG).", + "No masks yet. Choose a mask type above.": "Ancora nessuna maschera. Scegli un tipo di maschera qui sopra.", + "No matching commands": "Nessun comando corrispondente", + "No matching photos": "Nessuna foto corrispondente", + "No metadata found": "Nessun metadato trovato", + "No model credits were built into this copy.": "Questa copia non include i crediti dei modelli.", + "No named people in these photos": "In queste foto non ci sono persone con nome", + "No named people yet": "Ancora nessuna persona con nome", + "No new presets": "Nessun nuovo preset", + "No photo selected": "Nessuna foto selezionata", + "No photos": "Nessuna foto", + "No photos found": "Nessuna foto trovata", + "No photos in this folder": "Nessuna foto in questa cartella", + "No photos match the active filters": "Nessuna foto corrisponde ai filtri attivi", + "No photos match the filters (View → Clear Filters)": "Nessuna foto corrisponde ai filtri (Visualizza → Cancella filtri)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Ancora nessuna foto raw: le fotocamere compaiono qui quando le loro foto sono nella libreria.", + "No reference photo": "Nessuna foto di riferimento", + "No usable GPU found: rendering on the CPU": "Nessuna GPU utilizzabile: rendering sulla CPU", + "No versions yet. Create one to keep this look.": "Ancora nessuna versione. Creane una per conservare questo aspetto.", + "Noise": "Disturbo", + "Noise Reduction": "Riduzione disturbo", + "Non-raw photos": "Foto non raw", + "None": "Nessuno", + "Not Now": "Non ora", + "Nothing to compare": "Niente da confrontare", + "Nothing to survey": "Niente da esaminare", + "Nudge Develop Value": "Sposta di poco un valore di sviluppo", + "OK": "OK", + "Object": "Oggetto", + "Object Mask": "Maschera oggetto", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Le maschere Oggetto e Descrivi usano SAM 3, il modello di segmentazione di Meta. Non fa parte di LightCraft, e tutto il resto funziona anche senza.", + "October 2026": "Ottobre 2026", + "Off": "Disattivato", + "Offset X": "Spostamento X", + "Offset Y": "Spostamento Y", + "Opacity": "Opacità", + "Opacity (%)": "Opacità (%)", + "Open Library": "Apri libreria", + "Open Library…": "Apri libreria…", + "Open in": "Apri in", + "Open in Detail": "Apri in Dettaglio", + "Open the place in OpenStreetMap": "Apri il luogo in OpenStreetMap", + "Open…": "Apri…", + "Optics": "Ottica", + "Optimize Library": "Ottimizza libreria", + "Optional name": "Nome facoltativo", + "Options": "Opzioni", + "Orange": "Arancione", + "Orange Hue": "Tonalità arancione", + "Orange Luminance": "Luminanza arancione", + "Orange Saturation": "Saturazione arancione", + "Orientation": "Orientamento", + "Original": "Originale", + "Original + Settings": "Originale + impostazioni", + "Original extension without the dot": "Estensione originale senza il punto", + "Original file name without its extension": "Nome del file originale senza estensione", + "Original files": "File originali", + "Original offline · editing the smart preview": "Originale non disponibile · si modifica l'anteprima avanzata", + "Original offline · no smart preview": "Originale non disponibile · nessuna anteprima avanzata", + "Originals": "Originali", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Altre immagini (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Sovrapponi", + "Overwrite": "Sovrascrivi", + "Pages": "Pagine", + "Paint": "Dipingi", + "Paint only areas like the one under the brush": "Dipingi solo le zone simili a quella sotto il pennello", + "Paint over a distraction on the photo to remove it.": "Dipingi sopra un elemento di disturbo nella foto per rimuoverlo.", + "Paint overlay": "Sovrapposizione della pittura", + "Panels": "Pannelli", + "Panorama": "Panorama", + "Panorama Merge": "Unione panorama", + "Panorama Merge Preview": "Anteprima unione panorama", + "Panorama with Last Settings": "Panorama con le ultime impostazioni", + "Panoramas": "Panorami", + "Panorama…": "Panorama…", + "Parametric curve": "Curva parametrica", + "Paste": "Incolla", + "Paste Edit Settings": "Incolla impostazioni di modifica", + "Paste Metadata": "Incolla metadati", + "Paste Selected Settings": "Incolla impostazioni selezionate", + "Paste Selected Settings…": "Incolla impostazioni selezionate…", + "Paste Settings": "Incolla impostazioni", + "Paste Settings from Previous": "Incolla impostazioni dalla precedente", + "Paste from Previous": "Incolla dalla precedente", + "Paste pages": "Incolla pagine", + "Pastel Haze": "Foschia pastello", + "People": "Persone", + "Percent": "Percentuale", + "Percentage": "Percentuale", + "Performance": "Prestazioni", + "Person": "Persona", + "Perspective": "Prospettiva", + "Pet Eye": "Occhi degli animali", + "Pet Eye Catchlight": "Riflesso negli occhi degli animali", + "Photo": "Foto", + "Photo Grid": "Griglia foto", + "Photo Grid (G)": "Griglia foto (G)", + "Photo Merge": "Unione di foto", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Modifica nell'editor esterno (⇧⌘E) genera una copia TIFF a 16 bit, la mette in pila con l'originale e la apre qui (il nome di un'app su macOS, il percorso di un programma altrove).", + "Photos": "Foto", + "Photos per bracket (0 = auto)": "Foto per bracketing (0 = automatico)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Le foto viste in Locale ma mai aggiunte o modificate escono dal catalogo quando la loro cartella non viene sfogliata da questo tempo (controllo all'apertura della libreria). I file restano sul disco; sfogliando la cartella ricompaiono.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Le foto che arrivano in questa cartella (scatto collegato, scanner, app di sincronizzazione) vengono aggiunte appena sono complete.", + "Pick": "Scelta", + "Pick Flag": "Contrassegno scelta", + "Pick a colour on the photo with the eyedropper.": "Scegli un colore sulla foto con il contagocce.", + "Pick the sharpest photo of each burst": "Scegli la foto più nitida di ogni raffica", + "Picked": "Scelta", + "Picks": "Scelte", + "Pictures": "Immagini", + "Pixels": "Pixel", + "Point Color": "Colore puntuale", + "Point Curve": "Curva a punti", + "Point Curve Presets": "Preset di curva a punti", + "Point curve": "Curva a punti", + "Portrait": "Ritratto", + "Position": "Posizione", + "Preferences": "Preferenze", + "Preferences…": "Preferenze…", + "Prepare AI Masks": "Prepara le maschere IA", + "Preset": "Predefinito", + "Preset name": "Nome del preset", + "Presets": "Predefiniti", + "Presets & Profiles": "Preset e profili", + "Presets (Shift+P)": "Preset (Maiusc+P)", + "Presets and profiles": "Preset e profili", + "Presets ▾": "Preset ⌄", + "Press keys…": "Premi tasti…", + "Preview": "Anteprima", + "Preview Build Progress": "Avanzamento della generazione anteprime", + "Preview only": "Solo anteprima", + "Preview size": "Dimensione dell'anteprima", + "Preview unavailable": "Anteprima non disponibile", + "Previews": "Anteprime", + "Previous Photo": "Foto precedente", + "Print": "Stampa", + "Print…": "Stampa…", + "Profile": "Profilo", + "Profile Browser": "Browser dei profili", + "Profile Distortion": "Distorsione del profilo", + "Profile Menu": "Menu dei profili", + "Profile Vignetting": "Vignettatura del profilo", + "Profile:": "Profilo:", + "Profiles": "Profili", + "Projection": "Proiezione", + "Proof copy created": "Copia di prova creata", + "Properties": "Proprietà", + "Public domain": "Pubblico dominio", + "Pupil Size": "Dimensione pupilla", + "Purple": "Viola", + "Purple Amount": "Quantità viola", + "Purple Hue": "Tonalità viola", + "Purple Hue High": "Tonalità viola alta", + "Purple Hue Low": "Tonalità viola bassa", + "Purple Luminance": "Luminanza viola", + "Purple Saturation": "Saturazione viola", + "Put the locations you removed from Local back (no files change)": "Rimetti in Locale le posizioni che avevi tolto (nessun file cambia)", + "Quality": "Qualità", + "Query Photos": "Interroga le foto", + "Quick Collection": "Raccolta rapida", + "Quick Develop": "Sviluppo rapido", + "Quiet Green": "Verde quieto", + "Quit": "Esci", + "Quit Anyway": "Esci comunque", + "Quit LightCraft": "Esci da LightCraft", + "Quit with unsaved changes?": "Uscire con modifiche non salvate?", + "RAW decoding": "Decodifica RAW", + "Radial": "Radiale", + "Radial Gradient": "Sfumatura radiale", + "Radius": "Raggio", + "Random": "Casuale", + "Range": "Intervallo", + "Rate ★": "Valuta ★", + "Rate ★ and advance": "Valuta ★ e avanza", + "Rate ★★": "Valuta ★★", + "Rate ★★ and advance": "Valuta ★★ e avanza", + "Rate ★★★": "Valuta ★★★", + "Rate ★★★ and advance": "Valuta ★★★ e avanza", + "Rate ★★★★": "Valuta ★★★★", + "Rate ★★★★ and advance": "Valuta ★★★★ e avanza", + "Rate ★★★★★": "Valuta ★★★★★", + "Rate ★★★★★ and advance": "Valuta ★★★★★ e avanza", + "Rating": "Valutazione", + "Rating cleared": "Valutazione tolta", + "Ratings & flags": "Valutazioni e contrassegni", + "Raw": "Raw", + "Raw default": "Predefinito raw", + "Raw defaults": "Predefiniti raw", + "Raw files": "File raw", + "Raw photos": "Foto raw", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Le foto raw come DNG, con le modifiche incorporate. Le opzioni di dimensione, colore e uscita non si applicano.", + "Read Metadata from File": "Leggi i metadati dal file", + "Read mode": "Modalità lettura", + "Read the SAM License": "Leggi la licenza SAM", + "Reading": "Lettura", + "Reading Lightroom catalog…": "Lettura del catalogo Lightroom…", + "Reading folder…": "Lettura della cartella…", + "Real name": "Nome reale", + "Recent": "Recenti", + "Recent Keywords": "Parole chiave recenti", + "Recently Added": "Aggiunte di recente", + "Recently Deleted": "Eliminate di recente", + "Red": "Rosso", + "Red Eye": "Occhi rossi", + "Red Hue": "Tonalità rosso", + "Red Luminance": "Luminanza rosso", + "Red Primary": "Primario rosso", + "Red Saturation": "Saturazione rosso", + "Red channel": "Canale rosso", + "Red label": "Etichetta rossa", + "Red/Cyan Fringe": "Frange rosse/ciano", + "Redo": "Ripeti", + "Reference": "Riferimento", + "Reference View": "Vista di riferimento", + "Reference the files where they are": "Usa i file dove si trovano", + "Refine": "Rifinisci", + "Refine AI Mask Detail": "Rifinisci il dettaglio della maschera IA", + "Refine Edges": "Rifinisci i bordi", + "Refine Mask Edges": "Rifinisci i bordi della maschera", + "Refine Saturation": "Rifinisci la saturazione", + "Refining the mask's detail…": "Rifinitura del dettaglio della maschera…", + "Refresh Source": "Aggiorna l'origine", + "Refresh Source (/)": "Aggiorna l'origine (/)", + "Reject": "Scarta", + "Reject below focus": "Scarta sotto la soglia di messa a fuoco", + "Rejected": "Rifiutato", + "Rejects": "Scartate", + "Reliability": "Affidabilità", + "Relink Photo": "Ricollega foto", + "Reload Changed Files": "Ricarica i file modificati", + "Reload from Disk": "Ricarica dal disco", + "Remove": "Rimuovi", + "Remove (H)": "Rimuovi (H)", + "Remove CA": "Rimuovi AC", + "Remove Chromatic Aberration": "Rimuovi aberrazione cromatica", + "Camera local tone mapping": "Mappatura tonale locale della fotocamera", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Applica la mappatura tonale locale salvata nel raw (Apple ProRAW), come la mostra la fotocamera", + "Remove Disk from Library": "Rimuovi disco dalla libreria", + "Remove Disk from Library…": "Rimuovi disco dalla libreria…", + "Remove Face Box": "Rimuovi riquadro del volto", + "Remove Folder from Library": "Rimuovi cartella dalla libreria", + "Remove a filter above, or choose Clear all": "Togli un filtro qui sopra, oppure scegli Cancella tutto", + "Remove from Album": "Rimuovi dall'album", + "Remove from Favorites": "Rimuovi dai preferiti", + "Remove from Library…": "Rimuovi dalla libreria…", + "Remove from Local": "Rimuovi da Locale", + "Remove from Photo": "Rimuovi dalla foto", + "Remove from Selected Photos": "Rimuovi dalle foto selezionate", + "Remove from Stack": "Rimuovi dalla pila", + "Remove from Target Album": "Rimuovi dall'album di destinazione", + "Remove from survey": "Rimuovi dalla panoramica", + "Remove location info": "Rimuovi le informazioni sulla posizione", + "Remove shortcut": "Rimuovi scorciatoia", + "Remove this face box (undo with Edit ▸ Undo)": "Rimuovi questo riquadro del volto (annulla con Modifica → Annulla)", + "Remove this filter": "Rimuovi questo filtro", + "Remove this group": "Rimuovi questo gruppo", + "Remove this rule": "Rimuovi questa regola", + "Rename": "Rinomina", + "Rename Album": "Rinomina album", + "Rename Component": "Rinomina componente", + "Rename Folder": "Rinomina cartella", + "Rename Folder…": "Rinomina cartella…", + "Rename Keyword": "Rinomina parola chiave", + "Rename Keyword…": "Rinomina parola chiave…", + "Rename Mask": "Rinomina maschera", + "Rename Photos": "Rinomina foto", + "Rename Photos…": "Rinomina foto…", + "Rename Preset": "Rinomina preset", + "Rename Preview": "Anteprima della rinomina", + "Rename Template Tags": "Tag del modello di rinomina", + "Rename Version": "Rinomina versione", + "Rename…": "Rinomina…", + "Rendering": "Rendering", + "Rendering…": "Rendering in corso…", + "Reorder Masks": "Riordina maschere", + "Reset": "Reimposta", + "Reset All": "Reimposta tutto", + "Reset All Curves": "Reimposta tutte le curve", + "Reset All Keyboard Shortcuts": "Reimposta tutte le scorciatoie da tastiera", + "Reset Before to Import State": "Riporta Prima allo stato di importazione", + "Reset Control": "Reimposta controllo", + "Reset Crop": "Reimposta ritaglio", + "Reset Edits": "Reimposta modifiche", + "Reset Panel Layout": "Reimposta disposizione dei pannelli", + "Reset Parametric Curve": "Reimposta curva parametrica", + "Reset Section": "Reimposta sezione", + "Reset Tone Curve": "Reimposta curva dei toni", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Reimposta tutte le curve: curve a punti (tutti i canali) e parametrica (doppio clic su un canale per reimpostare solo quello)", + "Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)", + "Reshuffle": "Rimescola", + "Resize Face Box": "Ridimensiona riquadro del volto", + "Resize panel window": "Ridimensiona la finestra del pannello", + "Resize panels": "Ridimensiona pannelli", + "Resize to": "Ridimensiona a", + "Resolution (ppi)": "Risoluzione (ppi)", + "Restore": "Ripristina", + "Restore (no shortcut)": "Ripristina (nessuna scorciatoia)", + "Restore Library from Backup…": "Ripristina la libreria da un backup…", + "Restore Version": "Ripristina versione", + "Restore keeps their edits; Import as new deletes the old record first.": "Ripristina mantiene le loro modifiche; Importa come nuova elimina prima il vecchio record.", + "Restore them": "Ripristinale", + "Retouch": "Ritocco", + "Reverse the order": "Inverti l'ordine", + "Revert": "Ripristina", + "Review": "Revisione", + "Review Import": "Rivedi importazione", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic destro su una foto → Imposta come foto di riferimento, poi Visualizza → Vista di riferimento (Maiusc+R)", + "Right-click a preset to delete it": "Clic destro su un preset per eliminarlo", + "Rights Usage Terms": "Condizioni d'uso dei diritti", + "Rotate": "Ruota", + "Rotate Crop": "Ruota il ritaglio", + "Rotate Crop Aspect": "Ruota le proporzioni del ritaglio", + "Rotate Left": "Ruota a sinistra", + "Rotate Right": "Ruota a destra", + "Roughness": "Rugosità", + "Roundness": "Rotondità", + "Rules": "Regole", + "SAM 3 download stopped: it resumes where it left off next time.": "Download di SAM 3 interrotto: la prossima volta riprende da dove si è fermato.", + "Same as raw default": "Uguale al predefinito raw", + "Sample Mask Color": "Campiona il colore della maschera", + "Sample a colour on the photo": "Campiona un colore sulla foto", + "Saturation": "Saturazione", + "Saturation Range": "Intervallo di saturazione", + "Saturday": "Sabato", + "Save": "Salva", + "Save As…": "Salva con nome…", + "Save Color Label Set": "Salva set di etichette colore", + "Save Current Filter as Preset…": "Salva il filtro corrente come preset…", + "Save Current Keywords as Set…": "Salva le parole chiave correnti come set…", + "Save Export Preset": "Salva preset di esportazione", + "Save Filter Preset": "Salva preset di filtro", + "Save Keyword Set": "Salva set di parole chiave", + "Save Metadata Preset": "Salva preset di metadati", + "Save Metadata Preset…": "Salva preset di metadati…", + "Save Metadata to File": "Salva i metadati nel file", + "Save Point Curve Preset": "Salva preset di curva a punti", + "Save Point Curve…": "Salva curva a punti…", + "Save as Preset": "Salva come preset", + "Save as Set…": "Salva come set…", + "Save as Smart Album…": "Salva come album smart…", + "Save as set": "Salva come set", + "Save as…": "Salva con nome…", + "Save preset": "Salva preset", + "Saving Lightroom import index…": "Salvataggio dell'indice di importazione di Lightroom…", + "Scale": "Scala", + "Scan failed": "Scansione non riuscita", + "Scanning": "Scansione in corso", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "I punteggi restano sulle foto: filtra o crea album smart con Messa a fuoco e La migliore tra scatti simili.", + "Screen": "Schermo", + "Scroll menu down": "Scorri il menu in giù", + "Scroll menu up": "Scorri il menu in su", + "Search": "Cerca", + "Search Photos": "Cerca foto", + "Search commands or keys": "Cerca comandi o tasti", + "Search…": "Cerca…", + "Seasons": "Stagioni", + "Second Window": "Seconda finestra", + "Select": "Seleziona", + "Select All": "Seleziona tutto", + "Select Luminance Range": "Seleziona intervallo di luminanza", + "Select Mask": "Seleziona maschera", + "Select Photos": "Seleziona foto", + "Select Spot": "Seleziona macchia", + "Select a photo to edit": "Seleziona una foto da modificare", + "Select by": "Seleziona per", + "Select by Flag, Rating or Label": "Seleziona per contrassegno, valutazione o etichetta", + "Select photos, then choose View → Survey (N)": "Seleziona le foto, poi scegli Visualizza → Panoramica (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Seleziona due foto, poi scegli Visualizza → Confronta (Maiusc+C)", + "Selected": "Selezionati", + "Selecting…": "Selezione in corso…", + "Selenium Tone": "Viraggio al selenio", + "Send Feedback…": "Invia un feedback…", + "Sepia Tone": "Viraggio seppia", + "Sequence number zero-padded to N digits (1–9)": "Numero progressivo con zeri iniziali fino a N cifre (1-9)", + "Sequence number, counting from the start number": "Numero progressivo, a partire dal numero iniziale", + "Services": "Servizi", + "Set": "Imposta", + "Set Album Cover": "Imposta copertina dell'album", + "Set Before": "Imposta Prima", + "Set Color Label": "Imposta etichetta colore", + "Set Crop": "Imposta ritaglio", + "Set Develop Values": "Imposta valori di sviluppo", + "Set Flag": "Imposta contrassegno", + "Set Image Zoom and Pan": "Imposta zoom e spostamento dell'immagine", + "Set Keyboard Shortcut": "Imposta scelta rapida da tastiera", + "Set Mask Adjustments": "Imposta regolazioni della maschera", + "Set Point Curve": "Imposta curva a punti", + "Set Profile": "Imposta profilo", + "Set Rating": "Imposta valutazione", + "Set Top of Stack": "Imposta la prima della pila", + "Set as Album Cover": "Imposta come copertina dell'album", + "Set as Before": "Imposta come Prima", + "Set as Reference Photo": "Imposta come foto di riferimento", + "Set as Target Album": "Imposta come album di destinazione", + "Set as Target Album (B adds to it)": "Imposta come album di destinazione (B aggiunge)", + "Set as Top of Stack": "Imposta come prima della pila", + "Set date & time": "Imposta data e ora", + "Set name": "Imposta nome", + "Set the mix from the photo's colours": "Imposta il mix dai colori della foto", + "Settings": "Impostazioni", + "Settings file problem": "Problema nel file delle impostazioni", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Impostazioni da cui partono le nuove foto raw. Cambiarle non tocca le foto già nella libreria.", + "Settings pasted": "Impostazioni incollate", + "Settings to include": "Impostazioni da includere", + "Settings…": "Impostazioni…", + "Shade": "Ombra", + "Shadow": "Ombra", + "Shadows": "Ombre", + "Shadows Hue": "Tonalità delle ombre", + "Shadows Luminance": "Luminanza delle ombre", + "Shadows Saturation": "Saturazione delle ombre", + "Shadows Tint": "Tinta delle ombre", + "Shadows split": "Divisione delle ombre", + "Sharpen": "Nitidezza", + "Sharpening": "Contrasto", + "Sharpness": "Nitidezza", + "Shift": "Maiusc", + "Shift by": "Sposta di", + "Short Edge": "Lato corto", + "Show": "Mostra", + "Show All": "Mostra tutto", + "Show Clipping": "Mostra tagli", + "Show Deghost Overlay": "Mostra sovrapposizione della rimozione fantasmi", + "Show Hidden Local Locations": "Mostra le posizioni locali nascoste", + "Show Luminance Map": "Mostra mappa di luminanza", + "Show Mask": "Mostra maschera", + "Show Mask Overlay": "Mostra sovrapposizione maschera", + "Show Mask Pins": "Mostra i punti delle maschere", + "Show Original": "Mostra originale", + "Show Photo Counts": "Mostra il numero di foto", + "Show Photos with Keyword": "Mostra le foto con la parola chiave", + "Show Pins": "Mostra i punti", + "Show Source": "Mostra origine", + "Show Thumbnails": "Mostra miniature", + "Show commands without a shortcut": "Mostra i comandi senza scorciatoia", + "Show destination gamut warning": "Mostra avviso gamma di destinazione", + "Show display gamut warning": "Mostra avviso gamma dello schermo", + "Show file names": "Mostra i nomi dei file", + "Show in Explorer": "Mostra in Esplora file", + "Show in File Manager": "Mostra nel gestore file", + "Show in Finder": "Mostra nel Finder", + "Show on Map": "Mostra sulla mappa", + "Show overlay (O)": "Mostra sovrapposizione (O)", + "Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica", + "Show the Navigator while zoomed in": "Mostra il Navigatore quando lo zoom è attivo", + "Show the template tags; click one to insert it": "Mostra i tag del modello; fai clic su uno per inserirlo", + "Show/Hide Mask": "Mostra/nascondi maschera", + "Show/hide My Photos panel": "Mostra/nascondi il pannello Le mie foto", + "Shutter speed": "Tempo di scatto", + "Side panels": "Pannelli laterali", + "Sidecar names": "Nomi dei file collaterali", + "Size": "Dimensione", + "Size (% of short edge)": "Dimensione (% del lato corto)", + "Size limit": "Limite di dimensione", + "Skip": "Salta", + "Sky": "Cielo", + "Slideshow": "Presentazione", + "Slideshow paused": "Presentazione in pausa", + "Slideshow resumed": "Presentazione ripresa", + "Slideshow · Space pauses · Esc ends": "Presentazione · Spazio mette in pausa · Esc termina", + "Smart Album": "Album smart", + "Smart Album Rule Fields": "Campi delle regole degli album smart", + "Smart Preview Status": "Stato delle anteprime avanzate", + "Smart Previews Location": "Posizione delle anteprime avanzate", + "Smart preview available": "Anteprima avanzata disponibile", + "Smart previews": "Anteprime avanzate", + "Smart previews folder changed": "Cartella delle anteprime avanzate cambiata", + "Smoothness": "Uniformità", + "Soft B&W": "B/N morbido", + "Soft Pastel": "Pastello morbido", + "Soft Proofing": "Prova colore a video", + "Soft Skin": "Pelle morbida", + "Sort": "Ordina", + "Sort by": "Ordina per", + "Source": "Origine", + "Source code on GitHub": "Codice sorgente su GitHub", + "Spherical": "Sferica", + "Spiral": "Spirale", + "Split Stack": "Dividi la pila", + "Spring Fresh": "Primavera fresca", + "Square Grid": "Griglia quadrata", + "Square Grid (G toggles)": "Griglia quadrata (G alterna)", + "Square Grid: show file names and formats": "Griglia quadrata: mostra nomi e formati dei file", + "Stack": "Pila", + "Stack photos taken within this time of each other:": "Metti in pila le foto scattate entro questo intervallo l'una dall'altra:", + "Stacks": "Pile", + "Standard": "Standard", + "Star rating (0–5)": "Valutazione a stelle (0-5)", + "Start at": "Inizia da", + "Start number": "Numero iniziale", + "Starting": "Avvio in corso", + "Startup": "Avvio", + "State / Province": "Regione / provincia", + "Stop": "Interrompi", + "Stop Building Previews": "Interrompi la generazione delle anteprime", + "Stop Using as Target Album": "Non usare più come album di destinazione", + "Stopping…": "Interruzione in corso…", + "Straighten": "Raddrizza", + "Straighten Tool": "Strumento raddrizza", + "Strong Contrast": "Contrasto forte", + "Style": "Stile", + "Subfolder": "Sottocartella", + "Subfolder name": "Nome della sottocartella", + "Subject": "Soggetto", + "Subtract": "Sottrai", + "Subtract from this mask": "Sottrai da questa maschera", + "Suggestions": "Suggerimenti", + "Summer Haze": "Foschia estiva", + "Sunday": "Domenica", + "Survey": "Panoramica", + "Survey (N)": "Panoramica (N)", + "Swap": "Scambia", + "Swap Aspect (X)": "Scambia proporzioni (X)", + "Swap Before and After": "Scambia Prima e Dopo", + "Swap Before and After Settings": "Scambia le impostazioni di Prima e Dopo", + "Swap Compare Photos": "Scambia le foto del confronto", + "Switch light / dark theme": "Alterna tema chiaro/scuro", + "Sync Settings": "Sincronizza impostazioni", + "System": "Sistema", + "System Info": "Info sistema", + "System Info…": "Info sistema…", + "System default": "Predefinito di sistema", + "Table": "Tabella", + "Tags": "Tag", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Tag: vedi Tag accanto al modello. I file vengono rinominati sul disco (con i loro XMP collaterali); ai nomi già esistenti si aggiunge -1, -2…", + "Take a snapshot": "Acquisisci istantanea", + "Targeted Adjustment": "Regolazione mirata", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare i colori in quel punto", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare la curva in quel punto", + "Teal & Amber": "Ottanio e ambra", + "Teal & Orange": "Ottanio e arancio", + "Temp": "Temp.", + "Temperature": "Temperatura", + "Template": "Modello", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sessione temporanea: la tua libreria non è aperta, e niente di quello che fai qui viene salvato.", + "Text": "Testo", + "Text direction": "Direzione del testo", + "Texture": "Texture", + "That includes the photos in the folders inside it.": "Comprende anche le foto nelle cartelle al suo interno.", + "That key can't be used as a shortcut": "Quel tasto non si può usare come scorciatoia", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Il modello SAM 3 è installato: le maschere Oggetto e Descrivi sono pronte.", + "The download didn't work:": "Il download non è riuscito:", + "The number at the end of the original name (IMG_0042 → 0042)": "Il numero alla fine del nome originale (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "L'estensione originale viene sempre aggiunta; {ext} la mette anche dentro il nome.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "I file originali, invariati, ciascuno con un XMP collaterale che contiene le sue modifiche.", + "The photo has these settings": "La foto ha queste impostazioni", + "Theme": "Tema", + "They can be restored from Recently Deleted until it is emptied.": "Si possono ripristinare da Eliminate di recente finché non viene svuotato.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Finiscono in Eliminate di recente e si possono ripristinare; nessun file sul disco viene toccato.", + "Thirds": "Terzi", + "This Computer": "Questo computer", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Questa versione non può ancora scaricare il modello. Per installarlo a mano, metti model.safetensors, vocab.json e merges.txt del facebook/sam3 di Meta nella cartella qui sopra: Oggetto e Descrivi funzionano appena sono lì.", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Questa cartella non è disponibile (disco scollegato?). Le anteprime avanzate non vengono generate né usate finché non torna o non scegli un'altra cartella.", + "This photo can't be opened": "Impossibile aprire questa foto", + "Threshold": "Soglia", + "Thumbnail cache": "Cache delle miniature", + "Thursday": "Giovedì", + "Time zone": "Fuso orario", + "Tint": "Tinta", + "Title": "Titolo", + "Title metadata": "Metadati del titolo", + "Toggle Keyword from Set": "Applica o togli parola chiave dal set", + "Toggle Mask": "Attiva/disattiva maschera", + "Toggle Section": "Apri/chiudi sezione", + "Toggle Zoom": "Attiva/disattiva zoom", + "Tone Curve": "Curva dei toni", + "Tools": "Strumenti", + "Top Level": "Livello principale", + "Transfer": "Trasferimento", + "Treatment": "Trattamento", + "Triangle": "Triangolo", + "Try Again": "Riprova", + "Try Saving Again": "Riprova a salvare", + "Tuesday": "Martedì", + "Tungsten": "Tungsteno", + "Turn Off": "Disattiva", + "Turn on Include subfolders, or pick another folder under Local": "Attiva Includi sottocartelle, oppure scegli un'altra cartella in Locale", + "Type": "Tipo", + "Uncheck All": "Deseleziona tutto", + "Uncheck the files of this type": "Deseleziona i file di questo tipo", + "Undo": "Annulla", + "Unedited": "Non modificata", + "Unedited raws": "Raw non modificati", + "Unflag": "Togli contrassegno", + "Unflagged": "Senza contrassegno", + "Ungroup Stack": "Separa la pila", + "Unknown Date": "Data sconosciuta", + "Unknown tags stay as typed — check the preview for a {typo}.": "I tag sconosciuti restano come li hai scritti: controlla l'anteprima per un {typo}.", + "Unrated": "Senza valutazione", + "Unreadable": "Illeggibile", + "Update": "Aggiorna", + "Update Mask Shape": "Aggiorna la forma della maschera", + "Update Preset with Current Settings": "Aggiorna il preset con le impostazioni correnti", + "Update Rules from Current Filter": "Aggiorna le regole dal filtro corrente", + "Update Version": "Aggiorna versione", + "Update Version with Current Settings": "Aggiorna la versione con le impostazioni correnti", + "Update with Current Settings": "Aggiorna con le impostazioni correnti", + "Updated the edits saved in the external editor": "Modifiche salvate nell'editor esterno aggiornate", + "Upright": "Raddrizzamento", + "Urban": "Urbano", + "Use Keyword Set": "Usa set di parole chiave", + "Use Library Folder": "Usa la cartella della libreria", + "Use camera-specific defaults": "Usa i predefiniti specifici della fotocamera", + "Use the GPU for rendering": "Usa la GPU per il rendering", + "User Presets": "Preset utente", + "Username": "Nome utente", + "Value": "Valore", + "Variance": "Varianza", + "Version": "Versione", + "Version name": "Nome della versione", + "Versions": "Versioni", + "Versions (Shift+V)": "Versioni (Maiusc+V)", + "Vertical": "Verticale", + "Vertical inset (% of short edge)": "Margine verticale (% del lato corto)", + "Vertical text": "Testo verticale", + "Vibrance": "Vividezza", + "Videos": "Video", + "View": "Visualizza", + "Viewing and sharing": "Visione e condivisione", + "Vignette": "Vignettatura", + "Vignetting": "Vignettatura", + "Vintage": "Vintage", + "Virtual Copy": "Copia virtuale", + "Visible": "Visibile", + "Visualize Spots": "Visualizza macchie", + "Visualize Spots (A)": "Visualizza macchie (A)", + "Visualize range": "Visualizza intervallo", + "Vivid": "Vivace", + "Vivid Pop": "Vivace e brillante", + "Warm Film": "Pellicola calda", + "Warm Glow": "Bagliore caldo", + "Warm Print": "Stampa calda", + "Warm Table": "Tavola calda", + "Watched folder": "Cartella sorvegliata", + "Watermark": "Filigrana", + "Wednesday": "Mercoledì", + "What's New": "Novità", + "When rated or hovered": "Quando ha una valutazione o è sotto il puntatore", + "White Balance": "Bilanciamento del bianco", + "White Balance Selector": "Selettore del bilanciamento del bianco", + "White Balance Selector (W)": "Selettore del bilanciamento del bianco (W)", + "Whites": "Bianchi", + "Width": "Larghezza", + "Width (% of photo)": "Larghezza (% della foto)", + "Width (px)": "Larghezza (px)", + "Width × Height": "Larghezza × altezza", + "Will be replaced": "Verrà sostituita", + "Will be restored": "Verrà ripristinata", + "Window": "Finestra", + "Winter Blue": "Blu d'inverno", + "XMP Sidecar Preferences": "Preferenze dei file XMP collaterali", + "XMP sidecars": "File XMP collaterali", + "Year": "Anno", + "Yellow": "Giallo", + "Yellow Hue": "Tonalità giallo", + "Yellow Luminance": "Luminanza giallo", + "Yellow Saturation": "Saturazione giallo", + "Yellow label": "Etichetta gialla", + "Yes": "Sì", + "You can close this: the download continues, and resumes if interrupted.": "Puoi chiudere questa finestra: il download continua, e riprende se viene interrotto.", + "Your Name": "Il tuo nome", + "Your library couldn't be opened": "Impossibile aprire la tua libreria", + "Zoom": "Ingrandimento", + "Zoom 100%": "Zoom 100%", + "Zoom In": "Ingrandisci", + "Zoom Out": "Riduci", + "Zoom to Fit": "Zoom adatta", + "Zoom to page level": "Adatta alla pagina", + "a literal %": "un % letterale", + "a removed photo": "una foto rimossa", + "all": "tutte", + "and": "e", + "any": "almeno una", + "are empty": "sono vuoti", + "aren't empty": "non sono vuoti", + "blue": "blu", + "comma, separated": "separati, da virgola", + "contains": "contiene", + "copyrighted": "protetto da copyright", + "day 01–31": "giorno 01-31", + "days": "giorni", + "discarding smart previews": "eliminazione delle anteprime avanzate", + "doesn't contain": "non contiene", + "e.g. sky · the red car · car, road": "es. cielo · l'auto rossa · auto, strada", + "ends with": "finisce con", + "false": "no", + "green": "verde", + "hour 00–23": "ora 00-23", + "hours": "ore", + "image": "immagine", + "includes the folders inside it": "comprende le cartelle al suo interno", + "is": "è", + "is <": "è <", + "is >": "è >", + "is after": "è dopo", + "is before": "è prima", + "is between": "è tra", + "is empty": "è vuoto", + "is in the last": "è negli ultimi", + "is unknown": "è sconosciuto", + "is ≤": "è ≤", + "is ≥": "è ≥", + "isn't": "non è", + "isn't empty": "non è vuoto", + "isn't in the last": "non è negli ultimi", + "keep names — or e.g. {date}_{seq:3}": "mantieni i nomi, oppure ad es. {date}_{seq:3}", + "latitude, longitude": "latitudine, longitudine", + "memory only": "solo in memoria", + "minute": "minuto", + "month 01–12": "mese 01-12", + "months": "mesi", + "none": "nessuno", + "none (CPU rendering)": "nessuna (rendering sulla CPU)", + "none of": "nessuna", + "not available here": "non disponibile qui", + "of the following rules:": "delle regole seguenti:", + "or": "o", + "pick": "scelta", + "previews": "anteprime", + "publicDomain": "pubblico dominio", + "purple": "viola", + "raw": "raw", + "red": "rosso", + "reject": "scartata", + "saved in": "salvato in", + "second": "secondo", + "smart previews": "anteprime avanzate", + "starts with": "inizia con", + "the photos in view": "le foto nella vista", + "the selected photos": "le foto selezionate", + "this photo": "questa foto", + "true": "sì", + "unknown": "sconosciuto", + "video": "video", + "weeks": "settimane", + "year, 2 digits": "anno, 2 cifre", + "year, 4 digits": "anno, 4 cifre", + "years": "anni", + "yellow": "giallo", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} è l'ora di scatto; una foto che non ce l'ha usa l'ora in cui è stata importata.", + "© 2026 Your Name": "© 2026 Il tuo nome", + "© Your Name": "© Il tuo nome", + "− harder border · + softer border": "− bordo più netto · + bordo più morbido", + "≤ at most": "≤ al massimo", + "≥ at least": "≥ almeno", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "Any camera": "Qualsiasi fotocamera", + "Any lens": "Qualsiasi obiettivo", + "Any keyword": "Qualsiasi parola chiave", + "Label Set": "Set di etichette", + ", picked": ", scelta", + ", rejected": ", scartata", + "Preview only: editing the camera's embedded JPEG": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera", + "Captured from": "Scattate dal", + "Captured until": "Scattate fino al", + "Only": "Solo", + "Open Log Folder": "Apri la cartella dei log", + "Back to this person": "Torna a questa persona", + "AI Denoise": "Riduzione disturbo IA", + "AI Denoise Model": "Modello di riduzione disturbo IA", + "AI Denoise is off: press Use on a model.": "La riduzione disturbo IA è disattivata: premi Usa su un modello.", + "AI Denoise needs a model. Install one in Settings.": "La riduzione disturbo IA ha bisogno di un modello. Installane uno nelle Impostazioni.", + "Accept & Download": "Accetta e scarica", + "Already installed. Remove it before installing a replacement.": "Già installato. Rimuovilo prima di installarne un altro.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatico usa la scheda grafica quando è più veloce del processore: la prima volta che si elabora una foto, su questo computer vengono cronometrati entrambi.", + "Cache": "Cache", + "Checking and installing the model…": "Controllo e installazione del modello…", + "Checking…": "Controllo in corso…", + "Clear cache": "Svuota cache", + "Denoise Models": "Modelli di riduzione disturbo", + "Denoise Settings": "Impostazioni della riduzione disturbo", + "Denoise all photos with an Amount": "Riduci il disturbo in tutte le foto con una quantità", + "Downloaded and checked. Installing…": "Scaricato e verificato. Installazione…", + "Failed its last test": "Non ha superato l'ultimo test", + "Graphics card": "Scheda grafica", + "I have read these terms and accept them for my own use": "Ho letto queste condizioni e le accetto per uso personale", + "Inspect Denoise Model": "Esamina il modello di riduzione disturbo", + "Install Denoise Model": "Installa un modello di riduzione disturbo", + "Install from file…": "Installa da file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Installa un tuo modello ONNX con accanto denoise-model.json. Questa versione non offre download di modelli.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "La sua immagine con il disturbo ridotto non è ancora pronta, e le immagini non vengono generate da sole (Impostazioni, Riduzione disturbo IA).", + "LightCraft does not know this model, so it assumed:": "LightCraft non conosce questo modello, quindi ha ipotizzato:", + "Limit": "Limite", + "Make it now": "Generala ora", + "Make the denoised picture of the photos I am looking at": "Genera l'immagine con il disturbo ridotto delle foto che sto guardando", + "Next up for AI Denoise…": "Prossima per la riduzione disturbo IA…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Non viene scaricato niente finché non accetti. Il modello resta solo su questo computer; LightCraft non lo carica né lo condivide mai.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Vengono generate solo le foto con una quantità di riduzione disturbo IA sopra 0. Le esportazioni generano sempre la loro se manca.", + "Open page": "Apri pagina", + "Open the model's page": "Apri la pagina del modello", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Apre nel browser la pagina del modello, per leggerne i dettagli o scaricare il file da te.", + "Processor": "Processore", + "Set up AI Denoise…": "Configura la riduzione disturbo IA…", + "Speed": "Velocità", + "Starting…": "Avvio in corso…", + "Tested and works on this computer": "Testato e funzionante su questo computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Il modello di riduzione disturbo è installato e in uso: ora funziona il cursore Quantità riduzione disturbo IA in Dettaglio", + "The graphics card is set up when the next photo is made.": "La scheda grafica viene configurata quando si elabora la prossima foto.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Il modello resta solo su questo computer. LightCraft non lo carica né lo condivide mai.", + "The processor does the work.": "Il lavoro lo fa il processore.", + "There is no model file to install": "Non c'è nessun file di modello da installare", + "This build cannot run denoise models.": "Questa versione non può eseguire modelli di riduzione disturbo.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli di riduzione disturbo, quindi la riduzione disturbo IA non è disponibile. (L'app desktop ce l'ha.)", + "This file cannot be used yet": "Questo file non si può ancora usare", + "Tick the box to accept the model's terms first": "Prima spunta la casella per accettare le condizioni del modello", + "Try again": "Riprova", + "Try the graphics card again": "Riprova con la scheda grafica", + "Turn off": "Disattiva", + "Unknown licence": "Licenza sconosciuta", + "Use": "Usa", + "Waiting to start…": "In attesa di partire…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funziona sui file raw delle fotocamere con sensore standard (Bayer). Gli altri file mantengono la loro normale riduzione del disturbo.", + "cached picture": "immagine in cache", + "cached pictures": "immagini in cache", + "commercial use allowed": "uso commerciale consentito", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in uso", + "installed": "installato", + "makes AI Denoise available": "rende disponibile la riduzione disturbo IA", + "non-commercial use only": "solo uso non commerciale", + "photo has its denoised picture.": "foto ha la sua immagine con il disturbo ridotto.", + "photo waiting": "foto in attesa", + "photos have their denoised picture.": "foto hanno la loro immagine con il disturbo ridotto.", + "photos waiting": "foto in attesa", + "starts using it for AI Denoise": "comincia a usarlo per la riduzione disturbo IA", + "terms unclear": "condizioni poco chiare", + "the graphics card": "la scheda grafica", + "the graphics card is not used": "la scheda grafica non viene usata", + "Cancel Denoise": "Annulla riduzione disturbo", + "Cancel Denoise Model Download": "Annulla il download del modello di riduzione disturbo", + "Choose Denoise Model": "Scegli il modello di riduzione disturbo", + "Clear Denoise Cache": "Svuota la cache della riduzione disturbo", + "Denoise Model Downloads": "Download dei modelli di riduzione disturbo", + "Denoise Photos": "Riduci il disturbo delle foto", + "Denoise Pump": "Coda della riduzione disturbo", + "Denoise Status": "Stato della riduzione disturbo", + "Download Denoise Model": "Scarica il modello di riduzione disturbo", + "Remove Denoise Model": "Rimuovi il modello di riduzione disturbo", + "Test Denoise Model": "Testa il modello di riduzione disturbo", + "AI Denoise is enabled on the requesting photo": "La riduzione disturbo IA è attiva sulla foto che l'ha richiesta", + "After installation, AI Denoise will turn on for the requesting photo.": "Dopo l'installazione, la riduzione disturbo IA si attiverà sulla foto che l'ha richiesta.", + "Cancel pending action": "Annulla l'azione in sospeso", + "Install a denoise model to enable AI Denoise on this photo": "Installa un modello di riduzione disturbo per attivare la riduzione disturbo IA su questa foto", + "Pending photo action cancelled": "Azione sulla foto in sospeso annullata", + "Pending photo action cancelled because its library or photo changed": "Azione sulla foto in sospeso annullata perché la libreria o la foto sono cambiate", + "ONNX model": "Modello ONNX", + "commercial use subject to GPL conditions": "uso commerciale soggetto alle condizioni della GPL", + "Decrease Rating": "Diminuisci valutazione", + "Increase Rating": "Aumenta valutazione", + "Click to type a value": "Fai clic per scrivere un valore", + "Angle": "Angolo", + "Video": "Video", + "Copyrighted": "Protetta da copyright", + "Public Domain": "Pubblico dominio", + "Unknown": "Sconosciuto", + "Landscape (wide)": "Orizzontale (larga)", + "Portrait (tall)": "Verticale (alta)", + "Square": "Quadrato", + "In Color": "A colori", + "No Label": "Nessuna etichetta", + "Choose an album…": "Scegli un album…", + "No albums yet": "Ancora nessun album", + "Fix the marked rules to save this album.": "Correggi le regole segnate per salvare questo album.", + "Some rules need fixing (Edit Smart Album…):": "Alcune regole vanno corrette (Modifica album smart…):", + "isn't a field rules know": "non è un campo che le regole conoscono", + "can't be tested that way": "non si può verificare in quel modo", + "is yes or no": "è sì o no", + "needs a number": "richiede un numero", + "needs a time like 1/250 or 2": "richiede un tempo come 1/250 o 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "richiede una data come 2026, 2026-08 o 2026-08-14", + "has the first date after the second": "ha la prima data successiva alla seconda", + "needs two values": "richiede due valori", + "needs a number more than 0": "richiede un numero maggiore di 0", + "needs hours, days, weeks, months or years": "richiede ore, giorni, settimane, mesi o anni", + "isn't one of the choices": "non è tra le scelte possibili", + "needs something to look for (or use “is empty”)": "richiede qualcosa da cercare (oppure usa “è vuoto”)", + "needs something to look for (or use “isn't empty”)": "richiede qualcosa da cercare (oppure usa “non è vuoto”)", + "needs text": "richiede un testo", + "can't match any rating from 0 to 5": "non può corrispondere a nessuna valutazione da 0 a 5", + "needs an album": "richiede un album", + "names an album that no longer exists": "indica un album che non esiste più", + "can't test a folder; choose an album in it": "non si può verificare su una cartella; scegli un album al suo interno", + "This group is empty: add a rule or remove it.": "Questo gruppo è vuoto: aggiungi una regola o rimuovilo.", + "needs something to look for (or use “are empty”)": "richiede qualcosa da cercare (oppure usa “sono vuoti”)", + "needs something to look for (or use “aren't empty”)": "richiede qualcosa da cercare (oppure usa “non sono vuoti”)", + "Mo": "Lu", + "Tu": "Ma", + "We": "Me", + "Th": "Gi", + "Fr": "Ve", + "Sa": "Sa", + "Su": "Do", + "Pick a date": "Scegli una data", + "would make this album include itself through other smart albums": "farebbe includere questo album in se stesso attraverso altri album smart", + "Search albums": "Cerca album", + "No albums match": "Nessun album corrisponde", + "This is the album you're editing": "È l'album che stai modificando", + "It tests this album, so testing it back would loop": "Verifica questo album, quindi verificarlo a sua volta creerebbe un ciclo", + "Copy Name": "Nome della copia", + "In a Stack": "In una pila", + "File Extension": "Estensione del file", + "Video Duration": "Durata del video", + "Keyword Count": "Numero di parole chiave", + "People Count": "Numero di persone", + "Shutter Speed": "Tempo di scatto", + "Cropped": "Ritagliata", + "Keywords & People": "Parole chiave e persone", + "Description": "Descrizione", + "Camera Info": "Informazioni sulla fotocamera", + "Develop": "Sviluppo", + "Keyword List": "Elenco parole chiave", + "Filter Keywords": "Filtra parole chiave", + "No keywords yet": "Ancora nessuna parola chiave", + "No keywords match": "Nessuna parola chiave corrisponde", + "Keyword Name": "Nome della parola chiave", + "Synonyms": "Sinonimi", + "Separated by commas": "Separati da virgole", + "Keyword Tag Options": "Opzioni del tag parola chiave", + "Include on Export": "Includi nell'esportazione", + "Export Containing Keywords": "Esporta le parole chiave che la contengono", + "Export Synonyms": "Esporta i sinonimi", + "Create Keyword Tag": "Crea tag parola chiave", + "Edit Keyword Tag": "Modifica tag parola chiave", + "Create": "Crea", + "No photo has it; the keywords below it go too.": "Nessuna foto ce l'ha; vengono tolte anche le parole chiave sotto di essa.", + "Edit Keyword Tag…": "Modifica tag parola chiave…", + "Put New Keywords Inside This Keyword": "Metti le nuove parole chiave dentro questa", + "Purge Unused Keywords": "Elimina le parole chiave non usate", + "Delete Keyword…": "Elimina parola chiave…", + "Drop here for the top level": "Rilascia qui per il livello principale", + "Their photos and the keywords below them come together under one keyword.": "Le loro foto e le parole chiave sotto di esse si riuniscono sotto un'unica parola chiave.", + "Select photos to give them keywords.": "Seleziona delle foto per assegnare loro parole chiave.", + "Add to All Selected Photos": "Aggiungi a tutte le foto selezionate", + "Will Export": "Verrà esportata", + "No keywords are exported": "Nessuna parola chiave viene esportata", + "& Containing": "e contenenti", + "Edit Set…": "Modifica set…", + "Edit Keyword Set": "Modifica set di parole chiave", + "Keywords (⌥1–⌥9)": "Parole chiave (⌥1-⌥9)", + "A keyword set needs a name": "Un set di parole chiave ha bisogno di un nome", + "Save as a new set": "Salva come nuovo set", + "Recent Keywords is built in: choose another name": "Parole chiave recenti è predefinito: scegli un altro nome", + "Import Keywords…": "Importa parole chiave…", + "Export Keywords…": "Esporta parole chiave…", + "Import Keywords": "Importa parole chiave", + "Export Keywords": "Esporta parole chiave", + "Keyword Lists": "Elenchi di parole chiave", + "Synchronize Folder…": "Sincronizza cartella…", + "Synchronize Folder": "Sincronizza cartella", + "Synchronize": "Sincronizza", + "Scanning…": "Scansione…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Trova le foto aggiunte o mancanti in questa cartella sul disco, e i file XMP collaterali modificati da altre app", + "Bring the library up to date with this folder and the folders inside it:": "Aggiorna la libreria con questa cartella e le cartelle al suo interno:", + "Import new photos": "Importa le foto nuove", + "Remove missing photos from the library": "Togli dalla libreria le foto mancanti", + "Read metadata updates from XMP sidecars": "Leggi gli aggiornamenti dei metadati dai file XMP collaterali", + "The library is up to date with this folder.": "La libreria è aggiornata rispetto a questa cartella.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Le foto mancanti finiscono in Eliminate di recente e si possono ripristinare; nessun file sul disco viene toccato.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leggere un file collaterale sostituisce valutazione, etichette, metadati e modifiche della foto con quello che contiene.", + "Skipped: files already in the library elsewhere": "Saltati: file già presenti altrove nella libreria", + "Skipped: files that can't be read": "Saltati: file che non si possono leggere", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Questa cartella non c'è: è stata spostata o rinominata, oppure il suo disco non è collegato.", + "Relink photos whose file was renamed or moved": "Ricollega le foto il cui file è stato rinominato o spostato", + "A folder is already being synchronized": "Una cartella è già in sincronizzazione", + "Synchronizing…": "Sincronizzazione in corso…", + "Relinked": "Ricollegate", + "Removed": "Rimosse", + "Metadata read": "Metadati letti", + "Failed": "Non superato", + "Stopped": "Interrotto", + "Stopped by an error (see the log)": "Interrotta da un errore (vedi il log)", + "Not saved": "Non salvato", + "A folder is being synchronized": "Una cartella è in sincronizzazione", + "Wait for the import to finish": "Aspetta che l'importazione finisca", + "Appearance": "Aspetto", + "Appearance Mode": "Modalità aspetto", + "Sync with System": "Segui il sistema", + "Light Mode": "Tema chiaro", + "Dark Mode": "Modalità scura", + "Next Appearance Mode": "Modalità aspetto successiva", + "Light Theme": "Tema chiaro", + "Dark Theme": "Tema scuro", + "In Use": "In uso", + "Charcoal": "Carboncino", + "Midnight": "Mezzanotte", + "Silver": "Argento", + "Paper": "Carta", + "Show less": "Mostra meno", + "Quit while tasks are running?": "Uscire mentre ci sono attività in corso?", + "Exporting contact sheet": "Esportazione del foglio provini", + "Reading folder": "Lettura della cartella", + "Scanning folder": "Scansione della cartella", + "Building previews": "Generazione delle anteprime", + "Building smart previews": "Generazione delle anteprime avanzate", + "Discarding smart previews": "Eliminazione delle anteprime avanzate", + "Importing Lightroom catalog": "Importazione del catalogo Lightroom", + "Reading Lightroom catalog": "Lettura del catalogo Lightroom", + "Merging": "Unione", + "Downloading model": "Download del modello", + "Finding faces": "Ricerca dei volti", + "Synchronizing folder": "Sincronizzazione della cartella", + "Looking for changes": "Ricerca delle modifiche", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Questo è un catalogo di Lightroom, non una libreria di LightCraft. Vuoi importarlo in una libreria di LightCraft?", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L'importazione legge il catalogo senza modificarlo e aggiunge foto, modifiche, parole chiave e raccolte alla libreria aperta ora.", + "Albums hold library photos: Add to My Photos first": "Gli album contengono foto della libreria: prima aggiungi a Le mie foto", + "Add to My Photos First": "Prima aggiungi a Le mie foto", + "Fill Location from GPS": "Compila la posizione dal GPS", + "Download Place Names (GeoNames)": "Scarica i nomi dei luoghi (GeoNames)", + "Place Names Status": "Stato dei nomi dei luoghi", + "Fill Location": "Compila la posizione", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Compila Città, Regione e Paese vuoti dal GPS (nomi dei luoghi GeoNames, senza connessione)", + "Looking up places": "Ricerca dei luoghi", + "Downloading place names": "Download dei nomi dei luoghi", + "Place names downloaded": "Nomi dei luoghi scaricati", + "Place names download cancelled": "Download dei nomi dei luoghi annullato", + "Other…": "Altro…", + "Intent:": "Intento:", + "Perceptual": "Percettivo", + "Relative": "Relativo", + "Simulate Paper & Ink": "Simula carta e inchiostro", + "Content-Aware": "In base al contenuto", + "Sidebar Sections": "Sezioni della barra laterale", + "Reset Sidebar Sections": "Reimposta le sezioni della barra laterale", + "Hide This Section": "Nascondi questa sezione", + "No folders to browse": "Nessuna cartella da sfogliare", + "No photos yet": "Ancora nessuna foto", + "No imported folders yet": "Ancora nessuna cartella importata", + "Show Sidebar Section": "Mostra sezione della barra laterale", + "Move Sidebar Section": "Sposta sezione della barra laterale", + "New Library…": "Nuova libreria…", + "Open Recent Library": "Apri libreria recente", + "Recent Libraries": "Librerie recenti", + "Clear Recent Libraries": "Cancella le librerie recenti", + "No Recent Libraries": "Nessuna libreria recente", + "New Library: Choose Where to Put It": "Nuova libreria: scegli dove metterla", + "New Library": "Nuova libreria", + "Library name": "Nome della libreria", + "New Folder on Disk": "Nuova cartella sul disco", + "New Folder on Disk…": "Nuova cartella sul disco…", + "Move to Folder": "Sposta nella cartella", + "Move to Folder…": "Sposta nella cartella…", + "Move Selected Photos Here": "Sposta qui le foto selezionate", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Sposta i file e i loro XMP collaterali in questa cartella; non sovrascrive mai", + "Appearance Settings…": "Impostazioni aspetto…", + "Add to favorites": "Aggiungi ai preferiti", + "Remove from favorites": "Rimuovi dai preferiti", + "Add to Apple Photos": "Aggiungi a Foto di Apple", + "Adding to Apple Photos…": "Aggiunta a Foto di Apple…", + "not added to Apple Photos (cancelled)": "non aggiunte a Foto di Apple (annullato)", + "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.", + "Adding to Apple Photos": "Aggiunta a Foto di Apple", + "Add Face Model…": "Aggiungi modello dei volti…", + "Cancel Face Model Download": "Annulla il download del modello dei volti", + "Cancel Pending Photo Action": "Annulla l'azione in sospeso sulla foto", + "Choose Face Recognition Model": "Scegli il modello di riconoscimento dei volti", + "Choose a model below (Use) to start.": "Per cominciare, scegli un modello qui sotto (Usa).", + "Detect Faces": "Rileva i volti", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Serve a Rileva i volti (menu Foto): un piccolo download da fare una volta sola.", + "Download Face Model": "Scarica il modello dei volti", + "Drag Crop Handle": "Trascina la maniglia del ritaglio", + "Evaluate Face Recognition": "Valuta il riconoscimento dei volti", + "Face Model Downloads": "Download dei modelli dei volti", + "Face Models": "Modelli dei volti", + "Face Recognition On/Off": "Riconoscimento dei volti attivo/disattivo", + "Face detector": "Rilevatore di volti", + "Face recognition": "Riconoscimento dei volti", + "Faces": "Volti", + "Faces of a Person": "Volti di una persona", + "Go back to listing them by name": "Torna all'elenco per nome", + "Index Faces": "Indicizza i volti", + "Index Faces in the Background": "Indicizza i volti in background", + "Inspect Face Model File": "Esamina il file del modello dei volti", + "Install Face Model": "Installa il modello dei volti", + "Library Folders": "Cartelle della libreria", + "Name Face": "Dai un nome al volto", + "Name Faces": "Dai un nome ai volti", + "Recognise faces (experimental)": "Riconosci i volti (sperimentale)", + "Recognition models": "Modelli di riconoscimento", + "Remove Face Model": "Rimuovi il modello dei volti", + "Reorder Album": "Riordina album", + "Show Person": "Mostra persona", + "Sort Albums A–Z": "Ordina album A-Z", + "Sort Albums by Name": "Ordina album per nome", + "Sort Contents A–Z": "Ordina contenuti A-Z", + "Suggest Names for Faces": "Suggerisci nomi per i volti", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Suggerisce chi c'è in una foto a partire dai volti a cui hai dato un nome. Tutto resta sul tuo computer.", + "Test Face Model": "Testa il modello dei volti", + "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", + "Unnamed Faces": "Volti senza nome" +} diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 2df9f2b82..c5a7611e3 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -16,6 +16,7 @@ "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", "Automatic ({} MB)": "自動({}MB)", "Click {}:1": "クリック時 {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "カラースペース:Rec. 2020 {curve}(HDR AVIF)", "Create {n} Virtual Copies": "{n}枚の仮想コピーを作成", "Decoder: {reason}": "デコーダー:{reason}", "Delete \"{label}\"": "「{label}」を削除", @@ -94,6 +95,7 @@ "{n} photo{}": "{n}枚{:.0}", "{n} photo{} match · updates automatically as photos change": "{n}枚が一致{:.0}・写真の変更に応じて自動更新", "{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新", + "{n} selected photos · {pages} pages": "選択した写真 {n} 枚 · {pages} ページ", "{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{what} merge added": "{what}合成を追加しました", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index db459dd70..96df5e6b5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -10,6 +10,7 @@ ", rejected": "、不採用", "0: nothing is rejected.": "0:不採用にしません", "1 day": "1日", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 現在の編集 · トリミングせずに収める", "2026-04 or 2026-04-12": "2026-04 または 2026-04-12", "= exactly": "= と等しい", "A blank template keeps the original names.": "テンプレートが空の場合は元の名前のままです", @@ -199,6 +200,7 @@ "Choose Edit Settings to Copy": "コピーする編集設定を選択", "Choose Edit Settings to Copy…": "コピーする編集設定を選択…", "Choose Folder…": "フォルダを選択…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "確定後に PDF の保存先を選びます。PDF はビューアから印刷してください。", "Choose a photo in the grid or filmstrip": "グリッドまたはフィルムストリップから写真を選択してください", "Choose…": "選択…", "Cinematic": "シネマティック", @@ -245,6 +247,9 @@ "Color Priority": "カラー優先", "Color Range": "色範囲", "Color space": "色空間", + "Color space: Rec. 2020 PQ (HDR AVIF)": "カラースペース:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "カラースペース:Rec. 2020 PQ(HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "カラースペース:Rec. 2020 PQ、16 ビット(HDR PNG)", "Color space: sRGB (AVIF)": "色空間:sRGB(AVIF)", "Colors": "カラー", "Command Journal": "コマンド記録", @@ -260,6 +265,7 @@ "Confirm before deleting": "削除前に確認", "Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認", "Constrain Crop": "切り抜きを制限", + "Contact Sheet PDF": "コンタクトシート PDF", "Continue": "続行", "Contrast": "コントラスト", "Contributors": "コントリビューター", @@ -469,6 +475,7 @@ "File Type": "ファイルの種類", "File types:": "ファイルの種類:", "Filename": "ファイル名", + "Filename captions": "ファイル名のキャプション", "Fill": "画面いっぱい", "Film": "フィルム", "Filmstrip (/)": "フィルムストリップ(/)", @@ -504,6 +511,8 @@ "HDR Panorama Merge Preview": "HDRパノラマ合成のプレビュー", "HDR Panorama with Last Settings": "前回の設定でHDRパノラマ合成", "HDR Panoramas": "HDRパノラマ", + "HDR TIFF needs 32-bit float.": "HDR TIFF には 32 ビット浮動小数点が必要です。", + "HDR output": "HDR 出力", "HDR panorama": "HDRパノラマ", "HDR panoramas": "HDRパノラマ", "HDR with Last Settings": "前回の設定でHDR合成", @@ -549,6 +558,7 @@ "Last commit": "最後のコミット", "Last loupe render": "直近のルーペ描画", "Lens Blur": "レンズぼかし", + "Letter": "レター", "Library Info": "ライブラリ情報", "Library Preferences": "ライブラリの環境設定", "Library State": "ライブラリの状態", @@ -643,6 +653,7 @@ "People": "人物", "Person": "人物", "Pet Eye Catchlight": "ペットの目のキャッチライト", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "HDR で編集した写真:JPEG は HDR ゲインマップ付き(どこでも正しく表示され、HDR ディスプレイではハイライトがより明るい)、AVIF は 10 ビット、PNG と JPEG XL は 16 ビット Rec. 2020 PQ。TIFF には 32 ビット浮動小数点が必要です", "Photos per bracket (0 = auto)": "ブラケットあたりの枚数(0 = 自動)", "Pick Flag": "採用フラグ", "Pictures": "ピクチャ", @@ -961,6 +972,11 @@ "No new presets": "新しいプリセットはありません", "No photo selected": "写真が選択されていません", "No photos": "写真がありません", + "Your library is empty": "ライブラリは空です", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "読み込んだ写真はここに表示されます。選択しない限り、コピーや移動は行われません。", + "or drop photos and folders into this window": "または写真やフォルダーをこのウィンドウにドロップ", + "Import photos to get started": "写真を読み込んで始めましょう", + "or drop photos into this window": "または写真をこのウィンドウにドロップ", "No photos found": "写真が見つかりません", "No photos in this folder": "このフォルダに写真はありません", "No photos match the active filters": "現在のフィルターに一致する写真がありません", @@ -1447,6 +1463,7 @@ "Vibrance": "自然な彩度", "Videos": "動画", "View": "表示", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "PNG の cICP チャンクを読まないビューアは PQ の値を sRGB として表示するため、平坦で色あせて見えます。どこでも正しく見せるには JPEG(ゲインマップ)を使ってください。", "Viewing and sharing": "表示と共有", "Vignette": "周辺光量補正", "Vignetting": "周辺光量補正", diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index a6286d2f0..7de52b85e 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -289,5 +289,7 @@ "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 1969dd262..2d6262a9d 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -24,6 +24,7 @@ "Build Smart Previews ({scope})": "Gerar pré-visualizações inteligentes ({scope})", "Build Standard-Sized Previews ({scope})": "Gerar pré-visualizações em tamanho padrão ({scope})", "Click {}:1": "Clique em {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Espaço de cor: Rec. 2020 {curve} (AVIF HDR)", "Create {n} Virtual Copies": "Criar {n} cópias virtuais", "Decoder: {reason}": "Decodificador: {reason}", "Delete \"{label}\"": "Excluir \"{label}\"", @@ -118,6 +119,7 @@ "{n} photo{}": "{n} foto{}", "{n} photo{} match · updates automatically as photos change": "{n} foto{0} correspondente{0} · atualiza automaticamente conforme as fotos mudam", "{n} photo{} now · updates automatically as photos change": "{n} foto{} agora · atualiza automaticamente conforme as fotos mudam", + "{n} selected photos · {pages} pages": "{n} fotos selecionadas · {pages} páginas", "{sel_n} selected · {counted}": "{sel_n} selecionadas · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} {n} foto{}", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 14c30f937..b1f040945 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -10,6 +10,7 @@ ", rejected": ", rejeitada", "0: nothing is rejected.": "0: nada é rejeitado.", "1 day": "1 dia", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · edições atuais · ajustado sem cortar", "2026-04 or 2026-04-12": "2026-04 ou 2026-04-12", "= exactly": "= exatamente", "A blank template keeps the original names.": "Um modelo em branco mantém os nomes originais.", @@ -205,6 +206,7 @@ "Choose Edit Settings to Copy": "Escolher ajustes de edição a copiar", "Choose Edit Settings to Copy…": "Escolher ajustes de edição a copiar…", "Choose Folder…": "Escolher pasta…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Escolha onde salvar o PDF depois de confirmar. Imprima o PDF pelo seu visualizador.", "Choose a photo in the grid or filmstrip": "Escolha uma foto na grade ou na película", "Choose…": "Escolher…", "Cinematic": "Cinematográfico", @@ -255,6 +257,9 @@ "Color Range": "Intervalo de cor", "Color label cleared": "Rótulo de cor removido", "Color space": "Espaço de cor", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espaço de cor: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espaço de cor: Rec. 2020 PQ (JPEG XL HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espaço de cor: Rec. 2020 PQ, 16 bits (PNG HDR)", "Color space: sRGB (AVIF)": "Espaço de cor: sRGB (AVIF)", "Colors": "Cores", "Combine files…": "Combinar arquivos…", @@ -272,6 +277,7 @@ "Confirm before deleting": "Confirmar antes de excluir", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover as fotos para Excluídos recentemente", "Constrain Crop": "Restringir recorte", + "Contact Sheet PDF": "Folha de contato em PDF", "Continue": "Continuar", "Continue Without Saving": "Continuar sem salvar", "Contrast": "Contraste", @@ -496,6 +502,7 @@ "File name": "Nome do arquivo", "File types:": "Tipos de arquivo:", "Filename": "Nome do arquivo", + "Filename captions": "Legendas com o nome do arquivo", "Fill": "Preencher", "Fill Edges": "Preencher bordas", "Film": "Filme", @@ -556,6 +563,9 @@ "Full": "Completa", "Full Screen Preview": "Pré-visualização em tela cheia", "Hold View Until Refreshed": "Manter visualização até atualizar", + "HDR TIFF needs 32-bit float.": "TIFF HDR requer ponto flutuante de 32 bits.", + "HDR output": "Saída HDR", + "Letter": "Carta", "Lights Out": "Luzes apagadas", "Full screen mode": "Modo tela cheia", "Full size": "Tamanho completo", @@ -923,6 +933,11 @@ "No new presets": "Nenhuma predefinição nova", "No photo selected": "Nenhuma foto selecionada", "No photos": "Sem fotos", + "Your library is empty": "Sua biblioteca está vazia", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "As fotos que você importar aparecem aqui. Nada é copiado ou movido, a menos que você escolha.", + "or drop photos and folders into this window": "ou arraste fotos e pastas para esta janela", + "Import photos to get started": "Importe fotos para começar", + "or drop photos into this window": "ou arraste fotos para esta janela", "No photos found": "Nenhuma foto encontrada", "No photos in this folder": "Nenhuma foto nesta pasta", "No photos match the active filters": "Nenhuma foto corresponde aos filtros ativos", @@ -953,6 +968,7 @@ "Open Library": "Abrir biblioteca", "Open Library…": "Abrir biblioteca…", "Open Log Folder": "Abrir pasta de logs", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotos editadas em HDR: JPEG com mapa de ganho HDR (fica certo em qualquer lugar, realces mais brilhantes em telas HDR), AVIF em 10 bits, PNG e JPEG XL em 16 bits Rec. 2020 PQ; TIFF requer ponto flutuante de 32 bits", "Reading photos…": "Lendo fotos…", "Adding photos…": "Adicionando fotos…", "Open in": "Abrir em", @@ -1541,6 +1557,7 @@ "Vibrance": "Intensidade", "Videos": "Vídeos", "View": "Exibir", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Visualizadores que não leem o bloco cICP do PNG mostram os valores PQ como sRGB: chapados e desbotados. Para uma imagem que fique certa em qualquer lugar, use JPEG (mapa de ganho).", "Viewing and sharing": "Visualização e compartilhamento", "Vignette": "Vinheta", "Vignetting": "Vinheta", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11d899c38..35df503b0 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -38,6 +38,7 @@ "Build Standard-Sized Previews ({scope})": "Создать просмотры стандартного размера ({scope})", "Building previews for {} photos…": "Создание просмотров для фото: {}…", "Click {}:1": "Щелчок {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Цветовое пространство: Rec. 2020 {curve} (HDR AVIF)", "Couldn't open the editor: {e}": "Не удалось открыть редактор: {e}", "Create {n} Virtual Copies": "Создать виртуальные копии: {n}", "Creates {} stacks from {} of {scope}": "Будет создано стопок: {} из {} фото ({scope})", @@ -167,6 +168,7 @@ "{n} photo{}": "Фото: {n}{:.0}", "{n} photo{} match · updates automatically as photos change": "Подходящих фото: {n}{:.0} · обновляется автоматически при изменении фото", "{n} photo{} now · updates automatically as photos change": "Сейчас фото: {n}{:.0} · обновляется автоматически при изменении фото", + "{n} selected photos · {pages} pages": "Выбрано фото: {n} · страниц: {pages}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Похожих фото: {n}{:.0} · Вид → Сбросить фильтры, чтобы увидеть все", "{pid} (missing)": "{pid} (отсутствует)", "{sel_n} selected · {counted}": "Выбрано: {sel_n} · {counted}", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 29c723d03..5ec7cac1a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2,6 +2,7 @@ "+ Group": "+ Группа", "+ Rule": "+ Условие", "0: nothing is rejected.": "0: ничего не отклоняется.", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · текущие правки · вписано без обрезки", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Фильтр скрывает фото в этом представлении: измените его или сбросьте (Вид → Сбросить фильтры)", "A one-time download of about": "Однократная загрузка около", "A pen's pressure sets the size and flow of the brush": "Нажим пера задаёт размер и поток кисти", @@ -165,6 +166,7 @@ "Choose Edit Settings to Copy": "Выбрать настройки для копирования", "Choose Edit Settings to Copy…": "Выбрать настройки для копирования…", "Choose Folder…": "Выбрать папку…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "После подтверждения выберите, куда сохранить PDF. Печатайте PDF из программы просмотра.", "Choose a photo in the grid or filmstrip": "Выберите фото в сетке или на ленте", "Choose…": "Выбрать…", "Cinematic": "Кинематографичный", @@ -209,6 +211,9 @@ "Color Range": "Цветовой диапазон", "Color label cleared": "Цветовая метка сброшена", "Color space": "Цветовое пространство", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Цветовое пространство: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Цветовое пространство: Rec. 2020 PQ (HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Цветовое пространство: Rec. 2020 PQ, 16 бит (HDR PNG)", "Color space: sRGB (AVIF)": "Цветовое пространство: sRGB (AVIF)", "Combine files…": "Объединить файлы…", "Command Journal": "Журнал команд", @@ -223,6 +228,7 @@ "Confirm before deleting": "Подтверждать удаление", "Confirm before moving photos to Recently Deleted": "Подтверждать перемещение фото в «Недавно удалённые»", "Constrain Crop": "Ограничить кадрирование", + "Contact Sheet PDF": "PDF контактного листа", "Continue Without Saving": "Продолжить без сохранения", "Contrast": "Контрастность", "Convert to DNG": "Преобразовать в DNG", @@ -407,6 +413,7 @@ "File Size": "Размер файла", "File name": "Имя файла", "File types:": "Типы файлов:", + "Filename captions": "Подписи с именем файла", "Fill Edges": "Заполнить края", "Film": "Плёнка", "Filmstrip": "Лента", @@ -457,6 +464,9 @@ "Full": "Полный", "Full Screen Preview": "Полноэкранный просмотр", "Hold View Until Refreshed": "Удерживать вид до обновления", + "HDR TIFF needs 32-bit float.": "Для HDR TIFF нужен 32-битный формат с плавающей точкой.", + "HDR output": "Вывод HDR", + "Letter": "Letter", "Lights Out": "Выключить свет", "Full screen mode": "Полноэкранный режим", "Full size": "Полный размер", @@ -763,6 +773,11 @@ "No new presets": "Новых пресетов нет", "No photo selected": "Фото не выбрано", "No photos": "Нет фото", + "Your library is empty": "Библиотека пуста", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Импортированные фото появятся здесь. Ничего не копируется и не перемещается, пока вы сами этого не выберете.", + "or drop photos and folders into this window": "или перетащите фото и папки в это окно", + "Import photos to get started": "Импортируйте фото, чтобы начать", + "or drop photos into this window": "или перетащите фото в это окно", "No photos found": "Фото не найдены", "No photos in this folder": "В этой папке нет фото", "No photos match the active filters": "Нет фото, соответствующих активным фильтрам", @@ -788,6 +803,7 @@ "Open Library": "Открыть библиотеку", "Open Library…": "Открыть библиотеку…", "Open Log Folder": "Открыть папку журналов", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Фото, обработанные в HDR: JPEG с картой усиления HDR (выглядит правильно везде, на HDR-экранах светлые участки ярче), AVIF — 10 бит, PNG и JPEG XL — 16 бит Rec. 2020 PQ; для TIFF нужен 32-битный формат с плавающей точкой", "Reading photos…": "Чтение фото…", "Adding photos…": "Добавление фото…", "Open in": "Открыть в", @@ -1287,6 +1303,7 @@ "Vibrance": "Красочность", "Videos": "Видео", "View": "Вид", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Программы, не читающие блок cICP в PNG, показывают значения PQ как sRGB: плоско и блекло. Чтобы изображение везде выглядело правильно, используйте JPEG (карта усиления).", "Vignette": "Виньетка", "Vignetting": "Виньетирование", "Vintage": "Винтаж", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 2040873ca..8b1f90bbe 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -36,6 +36,7 @@ "Build Standard-Sized Previews ({scope})": "Створити перегляди стандартного розміру ({scope})", "Building previews for {} photos…": "Створення переглядів для фото: {}…", "Click {}:1": "Клацання {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Колірний простір: Rec. 2020 {curve} (HDR AVIF)", "Couldn't open the editor: {e}": "Не вдалося відкрити редактор: {e}", "Create {n} Virtual Copies": "Створити віртуальні копії: {n}", "Creates {} stacks from {} of {scope}": "Буде створено стосів: {} із {} ({scope})", @@ -162,6 +163,7 @@ "{n} photo{}": "Фото: {n}{:.0}", "{n} photo{} match · updates automatically as photos change": "Відповідних фото: {n}{:.0} · оновлюється автоматично зі зміною фото", "{n} photo{} now · updates automatically as photos change": "Зараз фото: {n}{:.0} · оновлюється автоматично зі зміною фото", + "{n} selected photos · {pages} pages": "Вибрано фото: {n} · сторінок: {pages}", "{pid} (missing)": "{pid} (відсутній)", "{sel_n} selected · {counted}": "Вибрано: {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 8bb244ac2..bb9650892 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1,6 +1,7 @@ { "% of all commits": "% усіх комітів", "A pen's pressure sets the size and flow of the brush": "Натиск пера задає розмір і потік пензля", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · поточні правки · вписано без обрізання", "About": "Про програму", "Add": "Додати", "Add to": "Додати до", @@ -31,6 +32,7 @@ "Cancel": "Скасувати", "Caption": "Підпис", "Change": "Змінити", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Після підтвердження виберіть, куди зберегти PDF. Друкуйте PDF із програми перегляду.", "Choose…": "Вибрати…", "City": "Місто", "Clarity": "Чіткість", @@ -46,10 +48,14 @@ "Color Priority": "Пріоритет кольору", "Color Range": "Колірний діапазон", "Color space": "Колірний простір", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Колірний простір: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Колірний простір: Rec. 2020 PQ (HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Колірний простір: Rec. 2020 PQ, 16 біт (HDR PNG)", "Colors": "Кольори", "Commits": "Коміти", "Company": "Компанія", "Compression": "Стиснення", + "Contact Sheet PDF": "PDF контактного аркуша", "Contrast": "Контрастність", "Contributors": "Учасники", "Copy": "Копіювати", @@ -91,6 +97,7 @@ "Favorites": "Улюблені", "Feather": "Розтушовування", "File": "Файл", + "Filename captions": "Підписи з назвою файлу", "Fill": "Заливка", "First": "Перший", "First commit": "Перший коміт", @@ -98,6 +105,8 @@ "Grab bag": "Випадкова добірка", "Group name": "Назва групи", "Hold View Until Refreshed": "Утримувати вигляд до оновлення", + "HDR TIFF needs 32-bit float.": "Для HDR TIFF потрібен 32-бітний формат із рухомою комою.", + "HDR output": "Вивід HDR", "Home": "Домашня тека", "Horizontal inset (% of short edge)": "Відступ по горизонталі (% короткого краю)", "ISO Speed": "Чутливість ISO", @@ -107,6 +116,7 @@ "Label": "Мітка", "Last commit": "Останній коміт", "Lens Blur": "Розмиття об’єктива", + "Letter": "Letter", "Line delta": "Різниця рядків", "Linear": "Лінійний", "Lines +/−": "Рядки +/−", @@ -124,6 +134,7 @@ "No matching commands": "Немає відповідних команд", "No model credits were built into this copy.": "У цю копію не вбудовано дані про використані моделі.", "Pen Pressure": "Натиск пера", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Фото, оброблені в HDR: JPEG із картою підсилення HDR (виглядає правильно всюди, на HDR-екранах світлі ділянки яскравіші), AVIF — 10 біт, PNG і JPEG XL — 16 біт Rec. 2020 PQ; для TIFF потрібен 32-бітний формат із рухомою комою", "Press keys…": "Натисніть клавіші…", "Preview unavailable": "Перегляд недоступний", "Radial": "Радіальний", @@ -304,6 +315,7 @@ "Vertical inset (% of short edge)": "Відступ по вертикалі (% короткого краю)", "Vibrance": "Соковитість", "View": "Вигляд", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Програми, які не читають блок cICP у PNG, показують значення PQ як sRGB: пласко й вицвіло. Щоб зображення всюди виглядало правильно, використовуйте JPEG (карта підсилення).", "Vignetting": "Віньєтування", "Visible": "Видимий", "Whites": "Білі тони", @@ -1099,6 +1111,11 @@ "No new presets": "Немає нових наборів налаштувань", "No photo selected": "Фото не вибрано", "No photos": "Немає фото", + "Your library is empty": "Бібліотека порожня", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Імпортовані фото з’являться тут. Нічого не копіюється й не переміщується, доки ви самі цього не оберете.", + "or drop photos and folders into this window": "або перетягніть фото й теки в це вікно", + "Import photos to get started": "Імпортуйте фото, щоб почати", + "or drop photos into this window": "або перетягніть фото в це вікно", "No photos found": "Фото не знайдено", "No photos in this folder": "У цій теці немає фото", "No photos match the active filters": "Жодне фото не відповідає активним фільтрам", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index f93aaf809..52dde3351 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -16,6 +16,7 @@ "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", "Automatic ({} MB)": "自动({0} MB)", "Click {}:1": "单击 {0}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "色彩空间:Rec. 2020 {curve}(HDR AVIF)", "Create {n} Virtual Copies": "创建 {n} 个虚拟副本", "Decoder: {reason}": "解码器:{reason}", "Delete \"{label}\"": "删除 \"{label}\"", @@ -94,6 +95,7 @@ "{n} photo{}": "{n} 张照片{0:.0}", "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}", "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}", + "{n} selected photos · {pages} pages": "已选 {n} 张照片 · {pages} 页", "{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {0:.0}%", "{what} merge added": "已添加 {what} 合并", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index d60edc75b..2b2e1efbc 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -10,6 +10,7 @@ ", rejected": ",排除", "0: nothing is rejected.": "0:不排除任何照片。", "1 day": "1 天", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 当前编辑 · 完整适合,不裁剪", "2026-04 or 2026-04-12": "2026-04 或 2026-04-12", "= exactly": "= 等于", "A blank template keeps the original names.": "模板留空则保留原始名称。", @@ -204,6 +205,7 @@ "Choose Edit Settings to Copy": "选择要复制的编辑设置", "Choose Edit Settings to Copy…": "选择要复制的编辑设置…", "Choose Folder…": "选择文件夹…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "确认后选择 PDF 的保存位置。请在查看器中打印 PDF。", "Choose a photo in the grid or filmstrip": "请在网格视图或胶片窗格中选择一张照片", "Choose…": "选择…", "Cinematic": "电影感", @@ -253,6 +255,9 @@ "Color Priority": "颜色优先", "Color Range": "颜色范围", "Color space": "色彩空间", + "Color space: Rec. 2020 PQ (HDR AVIF)": "色彩空间:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "色彩空间:Rec. 2020 PQ(HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "色彩空间:Rec. 2020 PQ,16 位(HDR PNG)", "Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)", "Colors": "颜色", "Combine files…": "合并文件…", @@ -270,6 +275,7 @@ "Confirm before deleting": "删除前确认", "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", "Constrain Crop": "限制裁剪", + "Contact Sheet PDF": "联系表 PDF", "Continue": "继续", "Continue Without Saving": "不保存继续", "Contrast": "对比度", @@ -492,6 +498,7 @@ "File Type": "文件类型", "File types:": "文件类型:", "Filename": "文件名", + "Filename captions": "文件名标题", "Fill": "填满", "First": "最早", "First commit": "首次提交", @@ -512,6 +519,8 @@ "HDR Merge Preview": "HDR 合并预览", "HDR Panorama Merge Preview": "HDR 全景合并预览", "HDR Panoramas": "HDR 全景", + "HDR TIFF needs 32-bit float.": "HDR TIFF 需要 32 位浮点。", + "HDR output": "HDR 输出", "HDR panorama": "HDR 全景", "HDR panoramas": "HDR 全景", "Has Edits": "有编辑", @@ -543,6 +552,7 @@ "Last commit": "最近一次提交", "Last loupe render": "最近一次放大视图渲染", "Lens Blur": "镜头模糊", + "Letter": "Letter", "Library saved": "图库已保存", "Licence:": "许可证:", "LightCraft could not start": "LightCraft 无法启动", @@ -597,6 +607,7 @@ "Pen Pressure": "笔压", "People": "人物", "Person": "人物", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "以 HDR 编辑的照片:JPEG 带 HDR 增益图(在任何地方都显示正确,在 HDR 显示器上高光更亮),AVIF 为 10 位,PNG 和 JPEG XL 为 16 位 Rec. 2020 PQ;TIFF 需要 32 位浮点", "Photos per bracket (0 = auto)": "每组包围曝光的照片数(0 = 自动)", "Pick Flag": "留用旗标", "Pictures": "图片", @@ -983,6 +994,11 @@ "No new presets": "没有新预设", "No photo selected": "未选择照片", "No photos": "没有照片", + "Your library is empty": "图库是空的", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "导入的照片会显示在这里。除非你选择,否则不会复制或移动任何文件。", + "or drop photos and folders into this window": "或将照片和文件夹拖放到此窗口", + "Import photos to get started": "导入照片以开始使用", + "or drop photos into this window": "或将照片拖放到此窗口", "No photos found": "未找到照片", "No photos in this folder": "此文件夹中没有照片", "No photos match the active filters": "没有照片符合当前筛选条件", @@ -1522,6 +1538,7 @@ "Vibrance": "鲜艳度", "Videos": "视频", "View": "视图", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "不读取 PNG cICP 块的查看器会把 PQ 值当作 sRGB 显示:平淡且发灰。要在任何地方都显示正确,请使用 JPEG(增益图)。", "Viewing and sharing": "查看与分享", "Vignette": "暗角", "Vignetting": "暗角", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index f3bd24b20..4ec0ef61a 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -21,6 +21,7 @@ "Build Smart Previews ({scope})": "建立智慧型預覽({scope})", "Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})", "Click {}:1": "點擊時 {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "色彩空間:Rec. 2020 {curve}(HDR AVIF)", "Create {n} Virtual Copies": "建立 {n} 張虛擬副本", "Decoder: {reason}": "解碼器:{reason}", "Delete \"{label}\"": "刪除「{label}」", @@ -114,6 +115,7 @@ "{n} photo{}": "{n} 張照片{:.0}", "{n} photo{} match · updates automatically as photos change": "{n} 張照片符合{:.0} · 隨照片變更自動更新", "{n} photo{} now · updates automatically as photos change": "目前 {n} 張照片{:.0} · 隨照片變更自動更新", + "{n} selected photos · {pages} pages": "已選取 {n} 張照片 · {pages} 頁", "{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ce3b17f32..d66be7af2 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -10,6 +10,7 @@ ", rejected": ",排除", "0: nothing is rejected.": "0:不排除任何照片。", "1 day": "1 天", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 目前的編輯 · 完整符合,不裁切", "2026-04 or 2026-04-12": "2026-04 或 2026-04-12", "= exactly": "= 等於", "A blank template keeps the original names.": "範本留空時保留原始名稱。", @@ -204,6 +205,7 @@ "Choose Edit Settings to Copy": "選擇要複製的編輯設定", "Choose Edit Settings to Copy…": "選擇要複製的編輯設定…", "Choose Folder…": "選擇資料夾…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "確認後選擇 PDF 的儲存位置。請在檢視器中列印 PDF。", "Choose a photo in the grid or filmstrip": "請在格狀檢視或底片顯示窗格中選擇照片", "Choose…": "選擇…", "Cinematic": "電影風格", @@ -253,6 +255,9 @@ "Color Priority": "色彩優先", "Color Range": "色彩範圍", "Color space": "色彩空間", + "Color space: Rec. 2020 PQ (HDR AVIF)": "色彩空間:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "色彩空間:Rec. 2020 PQ(HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "色彩空間:Rec. 2020 PQ,16 位元(HDR PNG)", "Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)", "Colors": "色彩", "Combine files…": "合併檔案…", @@ -270,6 +275,7 @@ "Confirm before deleting": "刪除前確認", "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認", "Constrain Crop": "限制裁切範圍", + "Contact Sheet PDF": "縮圖目錄 PDF", "Continue": "繼續", "Continue Without Saving": "繼續使用但不儲存", "Contrast": "對比", @@ -492,6 +498,7 @@ "File Type": "檔案類型", "File types:": "檔案類型:", "Filename": "檔名", + "Filename captions": "檔名說明", "Fill": "填滿", "First": "最早", "First commit": "首次提交", @@ -512,6 +519,8 @@ "HDR Merge Preview": "HDR 合併預覽", "HDR Panorama Merge Preview": "HDR 全景合併預覽", "HDR Panoramas": "HDR 全景", + "HDR TIFF needs 32-bit float.": "HDR TIFF 需要 32 位元浮點。", + "HDR output": "HDR 輸出", "HDR panorama": "HDR 全景", "HDR panoramas": "HDR 全景", "Has Edits": "有編輯", @@ -543,6 +552,7 @@ "Last commit": "最近一次提交", "Last loupe render": "最近一次放大檢視算繪", "Lens Blur": "鏡頭模糊", + "Letter": "Letter", "Library saved": "照片庫已儲存", "Licence:": "授權:", "LightCraft could not start": "LightCraft 無法啟動", @@ -597,6 +607,7 @@ "Pen Pressure": "筆壓", "People": "人物", "Person": "人物", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "以 HDR 編輯的照片:JPEG 附 HDR 增益圖(在任何地方都顯示正確,在 HDR 顯示器上亮部更亮),AVIF 為 10 位元,PNG 與 JPEG XL 為 16 位元 Rec. 2020 PQ;TIFF 需要 32 位元浮點", "Photos per bracket (0 = auto)": "每組包圍曝光的照片數(0 = 自動)", "Pick Flag": "精選旗標", "Pictures": "圖片", @@ -983,6 +994,11 @@ "No new presets": "沒有新預設集", "No photo selected": "尚未選取照片", "No photos": "沒有照片", + "Your library is empty": "照片庫是空的", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "匯入的照片會顯示在這裡。除非你選擇,否則不會複製或移動任何檔案。", + "or drop photos and folders into this window": "或將照片和資料夾拖放到此視窗", + "Import photos to get started": "匯入照片以開始使用", + "or drop photos into this window": "或將照片拖放到此視窗", "No photos found": "找不到照片", "No photos in this folder": "此資料夾沒有照片", "No photos match the active filters": "沒有照片符合目前的篩選條件", @@ -1522,6 +1538,7 @@ "Vibrance": "自然飽和度", "Videos": "影片", "View": "檢視", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "不讀取 PNG cICP 區塊的檢視器會把 PQ 值當作 sRGB 顯示:平淡且泛白。要在任何地方都顯示正確,請使用 JPEG(增益圖)。", "Viewing and sharing": "檢視與分享", "Vignette": "暗角", "Vignetting": "暗角", diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/button.rs new file mode 100644 index 000000000..f46156ed9 --- /dev/null +++ b/crates/ui-egui/src/button.rs @@ -0,0 +1,389 @@ +//! One button for the whole interface: what it is for ([`Kind`]: Primary, Secondary, Danger, +//! Ghost), how big ([`Size`]: Small, Medium, Large), and what it shows (text, an icon and text, +//! or an icon alone, which then needs a name for screen readers). It looks the same everywhere, +//! answers hover, press and the keyboard the same way, and its text always reads at WCAG AA on +//! its fill. +//! +//! ```ignore +//! if Button::new("Import Photos…").primary().widget("button:emptyImportPhotos").show(ui).clicked() { … } +//! Button::icon_only(Icon::Trash, "Delete mask").ghost().small().show(ui); +//! Button::new("Remove").icon(Icon::Trash).danger().show(ui); +//! ``` + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; + +/// What a button is for, which decides how loud it is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + /// The step to take: filled with the accent. One per place (a dialog's confirm, the welcome's + /// Import Photos…). + Primary, + /// Any other action: a neutral fill with a border. + #[default] + Secondary, + /// A step that removes or erases something, when it is the one being asked for: filled red. + Danger, + /// A quiet action among content (a toolbar, a banner's Hide): no fill until hovered. + Ghost, +} + +/// How big a button is: its height, padding, text and icon size. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Size { + /// 24 points: in toolbars, banners and dense rows. + Small, + /// 30 points: dialogs and panels. + #[default] + Medium, + /// 36 points: a page's one main action (the welcome, About's Discord link). + Large, +} + +impl Size { + pub const fn height(self) -> f32 { + match self { + Size::Small => 24.0, + Size::Medium => 30.0, + Size::Large => 36.0, + } + } + fn padding(self) -> f32 { + match self { + Size::Small => 8.0, + Size::Medium => 12.0, + Size::Large => 16.0, + } + } + fn text(self) -> f32 { + match self { + Size::Small => 12.0, + Size::Medium => 13.0, + Size::Large => 14.0, + } + } + fn icon(self) -> f32 { + match self { + Size::Small => 14.0, + Size::Medium => 16.0, + Size::Large => 18.0, + } + } + fn gap(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 6.0, + Size::Large => 8.0, + } + } + fn radius(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 5.0, + Size::Large => 6.0, + } + } +} + +/// Where the pointer and the keyboard are, for the colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Idle, + Hovered, + Pressed, + Disabled, +} + +/// The colours a button of `kind` takes in `state`: fill (if any), border (if any), and what is +/// drawn on it (text and icon). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub fill: Option, + pub border: Option, + pub content: Color32, +} + +/// WCAG 2 contrast of two opaque colours. +pub fn contrast(a: Color32, b: Color32) -> f32 { + let lum = |c: Color32| { + let ch = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) +} + +/// `c` toward black by `amount` (0 to 1). +fn darker(c: Color32, amount: f32) -> Color32 { + crate::widgets::lerp(c, Color32::BLACK, amount) +} + +/// `fill`, darkened as little as needed for white to read on it at 4.5:1 (a light theme's or a +/// bright red's fill would otherwise be too pale for white text). +fn fill_for_white(fill: Color32) -> Color32 { + let mut f = fill; + for _ in 0..20 { + if contrast(Color32::WHITE, f) >= 4.5 { + break; + } + f = darker(f, 0.06); + } + f +} + +/// The colours of a `kind` button in `state` with the theme `t`. +pub fn look(t: &Tokens, kind: Kind, state: State) -> Look { + let filled = |base: Color32| { + let base = fill_for_white(base); + // hover and press go darker: white on them only reads better + let fill = match state { + State::Idle | State::Disabled => base, + State::Hovered => darker(base, 0.12), + State::Pressed => darker(base, 0.22), + }; + Look { fill: Some(fill), border: None, content: Color32::WHITE } + }; + let look = match kind { + Kind::Primary => filled(t.accent), + Kind::Danger => filled(t.reject), + Kind::Secondary => Look { + fill: Some(match state { + State::Idle | State::Disabled => t.button, + State::Hovered => t.hover, + State::Pressed => t.pressed, + }), + border: Some(t.button_border), + content: t.text, + }, + Kind::Ghost => Look { + fill: match state { + State::Idle | State::Disabled => None, + State::Hovered => Some(t.hover), + State::Pressed => Some(t.pressed), + }, + border: None, + content: t.text, + }, + }; + if state == State::Disabled { + let fade = |c: Color32| c.gamma_multiply(0.45); + Look { fill: look.fill.map(fade), border: look.border.map(fade), content: fade(look.content) } + } else { + look + } +} + +/// A button, built and then shown with [`Button::show`] (in the layout) or [`Button::show_at`] +/// (in a given rectangle). +#[derive(Clone, Debug)] +pub struct Button<'a> { + text: Option<&'a str>, + icon: Option, + name: Option<&'a str>, + kind: Kind, + size: Size, + enabled: bool, + widget: Option, + tooltip: Option<&'a str>, + min_width: f32, + id_salt: Option, +} + +impl<'a> Button<'a> { + /// A button with `text` (an English string, translated when drawn). + pub fn new(text: &'a str) -> Self { + Button { + text: Some(text), + icon: None, + name: None, + kind: Kind::default(), + size: Size::default(), + enabled: true, + widget: None, + tooltip: None, + min_width: 0.0, + id_salt: None, + } + } + + /// A button showing `icon` alone; `name` is what a screen reader says and the tooltip shows. + pub fn icon_only(icon: Icon, name: &'a str) -> Self { + Button { text: None, icon: Some(icon), name: Some(name), ..Button::new("") } + } + + /// An icon before the text. + pub fn icon(mut self, icon: Icon) -> Self { + self.icon = Some(icon); + self + } + + pub fn kind(mut self, kind: Kind) -> Self { + self.kind = kind; + self + } + pub fn primary(self) -> Self { + self.kind(Kind::Primary) + } + pub fn secondary(self) -> Self { + self.kind(Kind::Secondary) + } + pub fn danger(self) -> Self { + self.kind(Kind::Danger) + } + pub fn ghost(self) -> Self { + self.kind(Kind::Ghost) + } + + pub fn size(mut self, size: Size) -> Self { + self.size = size; + self + } + pub fn small(self) -> Self { + self.size(Size::Small) + } + pub fn large(self) -> Self { + self.size(Size::Large) + } + + /// Off: drawn faded, and a click does nothing. + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on + /// screen, as every registered id is. + pub fn widget(mut self, id: impl Into) -> Self { + self.widget = Some(id.into()); + self + } + + /// Shown on hover (an icon-only button shows its name without one). + pub fn tooltip(mut self, text: &'a str) -> Self { + self.tooltip = Some(text); + self + } + + /// Told apart from buttons of the same widget id in other places (each dialog's confirm is + /// `button:dialogOk`: salted with the dialog, the keyboard's place doesn't carry from one + /// dialog to the next). + pub fn id_salt(mut self, salt: impl std::hash::Hash + std::fmt::Debug) -> Self { + self.id_salt = Some(egui::Id::NULL.with(salt)); + self + } + + /// At least this wide (a row of buttons of one width). + pub fn min_width(mut self, width: f32) -> Self { + self.min_width = width; + self + } + + fn font(&self, t: &Tokens) -> egui::FontId { + // the loud kinds speak a little bolder + match self.kind { + Kind::Primary | Kind::Danger => t.semibold(self.size.text()), + Kind::Secondary | Kind::Ghost => t.font(self.size.text()), + } + } + + fn galley(&self, ui: &Ui) -> Option> { + let t = Tokens::get(ui.ctx()); + self.text.filter(|s| !s.is_empty()).map(|s| ui.painter().layout_no_wrap(crate::i18n::tr(s).to_string(), self.font(&t), Color32::WHITE)) + } + + /// How big it is drawn. + pub fn desired_size(&self, ui: &Ui) -> Vec2 { + let h = self.size.height(); + let text_w = self.galley(ui).map_or(0.0, |g| g.size().x); + let w = match (self.icon.is_some(), text_w > 0.0) { + // an icon alone: a square + (true, false) => h, + (true, true) => self.size.padding() * 2.0 + self.size.icon() + self.size.gap() + text_w, + (false, _) => self.size.padding() * 2.0 + text_w, + }; + vec2(w.max(self.min_width), h) + } + + /// Show it where the layout puts it. + pub fn show(self, ui: &mut Ui) -> Response { + let size = self.desired_size(ui); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + self.show_at(ui, rect) + } + + /// Show it in `rect` (sized by the caller, e.g. a row of equal buttons). + pub fn show_at(self, ui: &mut Ui, rect: Rect) -> Response { + let t = Tokens::get(ui.ctx()); + // stable across frames and moves: its widget id (with its salt) when it has one, else its + // place in the ui (moved on, so the next button in the same ui gets another) + let id = match &self.widget { + Some(w) => ui.id().with(("lc-button", w.as_str(), self.id_salt)), + None => { + let id = ui.auto_id_with(("lc-button", self.text.or(self.name), self.id_salt)); + ui.skip_ahead_auto_ids(1); + id + } + }; + // a button in a disabled part of the window is disabled too + let enabled = self.enabled && ui.is_enabled(); + let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() }); + let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone())); + if let Some(w) = &self.widget { + crate::widgets::register(ui.ctx(), w.clone(), rect); + } + let state = if !enabled { + State::Disabled + } else if resp.is_pointer_button_down_on() { + State::Pressed + } else if resp.hovered() { + State::Hovered + } else { + State::Idle + }; + let lk = look(&t, self.kind, state); + // (its own rectangle and its focus ring: a long text never runs over its neighbours) + let p = &ui.painter_at(rect.expand(5.0)); + let radius = self.size.radius(); + if let Some(fill) = lk.fill { + p.rect_filled(rect, radius, fill); + } + if let Some(border) = lk.border { + p.rect_stroke(rect, radius, Stroke::new(1.0, border), StrokeKind::Inside); + } + // where the keyboard is: an accent ring around it + if resp.has_focus() { + p.rect_stroke(rect.expand(2.0), radius + 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + let galley = self.galley(ui); + let icon_s = self.size.icon(); + let content_w = match (&galley, self.icon) { + (Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x, + (Some(g), None) => g.size().x, + (None, Some(_)) => icon_s, + (None, None) => 0.0, + }; + let mut x = rect.center().x - content_w / 2.0; + if let Some(icon) = self.icon { + paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content); + x += icon_s + self.size.gap(); + } + if let Some(g) = galley { + let at = pos2(x, rect.center().y - g.size().y / 2.0); + p.galley_with_override_text_color(at, g, lk.content); + } + // the tooltip, or an icon's name, also while disabled (what the button would do). egui + // calls a response disabled only in a disabled ui: a button turned off on its own still + // answers hover, and takes the ordinary tooltip + match self.tooltip.or(if self.text.is_none() { self.name } else { None }) { + Some(tip) if resp.enabled() => resp.on_hover_text(crate::i18n::tr(tip)), + Some(tip) => resp.on_disabled_hover_text(crate::i18n::tr(tip)), + None => resp, + } + } +} diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 84a5e1d6b..1aec84820 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -132,6 +132,59 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result Result { + use lightcraft_engine::print::{compose, output}; + if app.export.is_some() { + return Err("an export is already running".into()); + } + let path = params.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("missing path")?.to_string(); + app.session.check_write_target(&path)?; + let guard = app.session.original_guard(); + let settings = app.session.print.clone(); + let ids = app.session.targets(params).map_err(|e| e.to_string())?; + let prepared = compose::prepare(&mut app.session, &settings, &ids)?; + let total = prepared.len(); + let title = std::path::Path::new(&path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let write = app.services.write_shared.clone(); + let progress = Arc::new(Mutex::new((0, String::new()))); + let cancel = Arc::new(AtomicBool::new(false)); + let (p, c) = (progress.clone(), cancel.clone()); + let (tx, rx) = channel(); + let photos = ids.len(); + let work = move || { + let result = prepared + .run(&mut |done, name| { + if let Ok(mut g) = p.lock() { + *g = (done, name.to_string()); + } + !c.load(Ordering::Relaxed) + }) + .and_then(|pages| { + if c.load(Ordering::Relaxed) { + return Err("Print cancelled".into()); + } + let bytes = output::write(&pages, &settings.job, &title)?; + guard.check(std::path::Path::new(&path))?; + match write { + Some(write) => write(&path, &bytes)?, + None => lightcraft_engine::export::write_file(&path, &bytes)?, + } + Ok(vec![json!({"path": path, "pages": pages.len(), "photos": photos, "bytes": bytes.len(), "print": true})]) + }); + let _ = tx.send(result.map(|files| (files, None, c.load(Ordering::Relaxed)))); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::Builder::new().name("print".into()).spawn(work).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + work(); + let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone())); + row.progress(0, total as u64); + app.export = Some(ExportTask { total, progress, cancel, rx, guard: row }); + Ok(json!({"background": true, "total": total})) +} + /// The toast's account of [`AfterExport::run`]'s Apple Photos outcome. pub fn apple_photos_note(outcome: &Value) -> String { if let Some(e) = outcome.get("error").and_then(Value::as_str) { @@ -200,6 +253,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(sheet) = sheet { m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); } + if let Some(pdf) = files.first().filter(|f| f["print"] == true) { + m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); + } if skipped > 0 { m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); } @@ -285,7 +341,9 @@ mod contact_sheet_tests { crate::pick::poll(&mut app, &ctx); assert!(app.pending_picks.is_empty()); assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast); - let deadline = Instant::now() + Duration::from_secs(20); + // a hang guard, not a speed check: the render queues behind other tests' demo scenes (generated one at a + // time process-wide), which on a loaded machine takes minutes + let deadline = Instant::now() + Duration::from_secs(300); loop { // Poll the real worker without starting unrelated gallery renders each frame. poll(&mut app, &ctx); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index a2cf4d1c0..119474ebd 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -128,6 +128,7 @@ language_table! { Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); Pl, "pl", "Polski", "Latn", include_str!("../locales/pl.json"); Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json"); + It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -681,7 +682,7 @@ mod tests { lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); faces.dedup(); if faces.is_empty() { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } ctx.fonts_mut(|fonts| { @@ -753,6 +754,7 @@ mod tests { ("app.language.ukrainian", Locale::Uk), ("app.language.polish", Locale::Pl), ("app.language.czech", Locale::Cs), + ("app.language.italian", Locale::It), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); @@ -1007,6 +1009,109 @@ mod tests { set_language(Locale::En); } + /// Italian's coverage of every catalog message and display label, reported like Ukrainian's. + #[test] + fn italian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { + let catalog = Locale::It.catalog(); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("it lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in lightcraft_catalog::rules::FIELDS { + labels.push(label); + labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); + } + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("it lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + } + + #[test] + fn italian_switches_persists_formats_dates_and_preserves_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.italian", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::It); + assert_eq!(crate::menubar::checked(&app, "app.language.italian"), Some(true)); + assert_eq!(tr("Light"), "Luce"); + assert_eq!(tr("Landscape"), "Paesaggio"); + assert_eq!(tr("Home"), "Cartella personale"); + assert_eq!(date_group_label("2026-09-20", false), "Domenica 20/09/2026"); + assert_eq!(date_group_label("2026-09", false), "09/2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Domenica 20/09/2026 16:04:05"); + // Italian agrees adjectives with the count ("1 foto importata", "2 foto importate"), so the + // count follows a label instead, which reads right for every number; the English plural + // suffix is consumed and never shows. + for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Foto importate: {n}")); + assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("{n} file, cartelle: {n}")); + let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); + let expected = if n == 1 { "1 immagine in cache".to_string() } else { format!("{n} immagini in cache") }; + assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); + } + // This trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + assert_eq!( + tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), + "Riduzione disturbo di IMG.CR3: 1 di 4 riquadri · in attesa: 5" + ); + let name = "Color {title} 日本語"; + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Foto aggiunte a “{name}”: 22")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), "Bagliore caldo"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"it""#)); + assert_eq!(serde_json::from_str::(&saved).unwrap().language, Locale::It); + let ctx = egui::Context::default(); + for code in ["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::It)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::It); + } + set_language(Locale::En); + } + + #[test] + fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains("Le mie foto") && text.contains("Tutte le foto"), "{text}"); + for (command, title) in + [("app.about", "Informazioni su LightCraft"), ("app.shortcuts", "Scorciatoie da tastiera"), ("app.settings", "Impostazioni")] + { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains(title), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "àèéìòùÀÈÉÌÒÙ’“”«»" + .chars() + .chain(Locale::It.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Italian glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + /// Every command, control, rule and rename label, and every line of What's New, is a message a /// catalog can translate. What a language lacks is reported, not failed (like /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every @@ -1148,7 +1253,7 @@ mod tests { } #[test] - fn german_and_ukrainian_buttons_fit_the_bottom_bar_and_export_dialog() { + fn german_ukrainian_and_italian_buttons_fit_the_bottom_bar_and_export_dialog() { fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { match shape { egui::epaint::Shape::Text(shape) => { @@ -1158,7 +1263,7 @@ mod tests { _ => {} } } - for language in [Locale::De, Locale::Uk] { + for language in [Locale::De, Locale::Uk, Locale::It] { let ctx = fonts_ctx(&[]); let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); @@ -1286,7 +1391,7 @@ mod tests { let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; for (language, sample) in samples { if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } set_language(language); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 11b35f1e3..e851463d1 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1461,7 +1461,7 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: for (i, tok) in TOKENS.iter().enumerate() { let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( if tok.aliases.is_empty() { - "Insert at the cursor".to_string() + crate::i18n::tr("Insert at the cursor").to_string() } else { crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) }, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4520bfa8..b06e136b9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod album_picker; pub mod brush_live; +pub mod button; pub mod canvas_keys; mod command_error; pub mod control; @@ -28,6 +29,7 @@ pub mod merge; mod model_setup; pub mod panels; pub mod pick; +pub mod print_view; pub mod recheck; pub mod region; pub mod render; @@ -57,6 +59,8 @@ mod tests_before_after; #[cfg(test)] mod tests_browse_active; #[cfg(test)] +mod tests_button; +#[cfg(test)] mod tests_canvas_keys; #[cfg(test)] mod tests_compare; @@ -73,8 +77,14 @@ mod tests_date_picker; #[cfg(test)] mod tests_dialog_fit; #[cfg(test)] +mod tests_dialog_primary; +#[cfg(test)] mod tests_dialogs; #[cfg(test)] +mod tests_edit_layout; +#[cfg(test)] +mod tests_empty_library; +#[cfg(test)] mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; @@ -123,6 +133,8 @@ mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_profiles_layout; #[cfg(test)] mod tests_quit_unsaved; @@ -765,7 +777,7 @@ impl LightcraftApp { } (None, true) => { self.ui.unsaved_seen = false; - self.toast(ctx, "Library saved"); + self.toast(ctx, crate::i18n::tr("Library saved")); } (Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry (None, false) => {} @@ -1071,13 +1083,17 @@ impl LightcraftApp { if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + // (once a frame: a pass drawn again after a discard has no input of its own, and still the + // widgets of the pass thrown away, where a request would find them out of place) + if ctx.current_pass_index() == 0 { + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + self.drain_control(ctx); } - self.last_time = now; - self.drain_control(ctx); self.apply_settings(ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); @@ -1102,7 +1118,7 @@ impl LightcraftApp { if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) { - self.toast(ctx, "Updated the edits saved in the external editor"); + self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); } } self.ui.was_focused = focused; @@ -1632,6 +1648,9 @@ pub struct Caches { /// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]). rules_view: Option<(u64, std::sync::Arc)>, pub rules_view_scans: usize, + /// A count an open dialog shows ([`Caches::dialog_count`]), by what it counts. + dialog_count: Option<(u64, usize)>, + pub dialog_count_scans: usize, /// The problems of every saved smart album that has some ([`Caches::smart_album_problems`]). smart_problems: Option<(u64, std::sync::Arc>>)>, pub smart_problem_scans: usize, @@ -1953,6 +1972,21 @@ impl Caches { v } + /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs + /// again only when the catalog or `what` (what is counted) changes, not every frame. + pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { + let k = key_of((cat.revision, what)); + if let Some((key, n)) = self.dialog_count + && key == k + { + return n; + } + let n = count(); + self.dialog_count_scans += 1; + self.dialog_count = Some((k, n)); + n + } + /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. pub fn smart_album_problems( diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index cab410112..668ea3fbd 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -706,7 +706,7 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option> v } "Add to Album" => { - let targets = app.session.targets(&Value::Null); + let targets = app.session.selected(); let sel = !targets.is_empty(); // albums hold library photos (issue #684): a Local-only selection is told what to do first let local_only = crate::panels::grid::local_only(app, &targets); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 960d2ad95..11461bd52 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,6 +26,7 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), + ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -50,6 +51,7 @@ pub fn language_from_command(id: &str) -> Option { "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), "app.language.polish" => Some(crate::i18n::Locale::Pl), "app.language.czech" => Some(crate::i18n::Locale::Cs), + "app.language.italian" => Some(crate::i18n::Locale::It), _ => None, } } @@ -192,6 +194,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), ("dialog.export", "Export…", None, "File"), + ("dialog.print", "Print…", Some("Cmd+P"), "File"), + ("app.print", "Print to PDF", None, ""), ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), ("app.contactSheet", "Export Contact Sheet PDF", None, ""), ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), @@ -594,7 +598,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::compare::make_select(app), "view.autoAdvance" => { app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); + app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); Ok(json!({"autoAdvance": app.ui.auto_advance})) } "view.back" => { @@ -1340,6 +1344,28 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.print = Default::default(); + app.ui.dialog = Some(Dialog::Print); + Ok(Value::Null) + } + "app.print" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_print(app, ¶ms) + } "app.contactSheet" => { if app.export.is_some() { return Some(Err("an export is already running".into())); @@ -2080,7 +2106,9 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { match id { "library.selectOnlyActive" => app.session.active().is_some(), "view.thumbSizeUp" | "view.thumbSizeDown" => thumb_size_view(app), - "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), + "dialog.contactSheet" | "app.contactSheet" | "dialog.print" | "app.print" => { + !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none() + } "dialog.slideshowExport" | "app.slideshowExport" => { !cfg!(target_arch = "wasm32") && !app.session.slideshow_export.as_ref().is_some_and(|r| r.running()) } @@ -2124,7 +2152,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare, "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => app.ui.slideshow.is_some(), s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { - app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() + app.session.selected().len() >= 2 && app.merge.final_task.is_none() } _ => true, } @@ -2176,7 +2204,7 @@ pub fn delete_in_album(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { app.toast(ctx, crate::i18n::tr("Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo")); return true; } - if app.session.targets(&json!({})).is_empty() { + if app.session.selected().is_empty() { return true; } match app.run("album.removePhotos", json!({"id": aid.0})) { @@ -2197,7 +2225,7 @@ pub fn confirm_delete(app: &mut LightcraftApp) -> bool { if !app.ui.settings.confirm_delete { return false; } - let count = app.session.targets(&json!({})).len(); + let count = app.session.selected().len(); if count == 0 { return false; } @@ -2315,7 +2343,7 @@ fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> } fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result { - let mut ids = app.session.targets(p); + let mut ids = app.session.targets(p).map_err(|e| e.to_string())?; ids.dedup(); if ids.is_empty() { return Err("no photo selected".into()); diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index 02e4ddec2..d4c9a0bc6 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -150,7 +150,7 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b /// Open the merge dialog for the selection. pub fn open(app: &mut LightcraftApp, command: &str) -> Result { - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); if ids.len() < 2 { return Err("select at least 2 photos to merge".into()); } @@ -187,7 +187,7 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result Result { - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); if ids.len() < 2 { return Err("select at least 2 photos to merge".into()); } @@ -202,7 +202,7 @@ pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result if app.merge.final_task.is_some() { return Err("a merge is already running".into()); } - let ids = app.session.targets(p); + let ids = app.session.targets(p).map_err(|e| e.to_string())?; let sets = app.session.catalog.bracket_sets(&ids); if sets.is_empty() { return Err(lightcraft_engine::merge::NO_BRACKET_SETS.into()); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 0f8803126..c83acf8a4 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -384,7 +384,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { - super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, canvas); + } else { + super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + } return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a7779fcd6..485080bd6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -22,7 +22,7 @@ pub(crate) fn rename_preview( ) -> (Option>, usize) { use std::hash::{Hash, Hasher}; type Rows = std::sync::Arc>>>; - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids); let total = photos.len(); let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -71,7 +71,7 @@ fn watermark_preview( type Base = std::sync::Arc>)>>; type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); let t = Tokens::get(ui.ctx()); - let Some(&id) = app.session.targets(&json!({})).first() else { return }; + let Some(&id) = app.session.selected().first() else { return }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); let export_short = out_w.min(out_h); @@ -176,6 +176,7 @@ pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); fn window_id(dialog: &Dialog) -> egui::Id { egui::Id::new(match dialog { Dialog::Import { .. } => "lightcraft-import-dialog", + Dialog::Print => "lightcraft-print-dialog", Dialog::Settings { .. } => "lightcraft-settings-dialog", _ => "lightcraft-dialog", }) @@ -254,6 +255,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Export { .. } => "Export", Dialog::ContactSheet { .. } => "Contact Sheet PDF", Dialog::SlideshowExport { .. } => "Export Slideshow", + Dialog::Print => "Print", Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", @@ -277,6 +279,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); + // The Print dialog sizes itself to the window and scrolls its settings column on its own. + let print = matches!(dlg, Dialog::Print); let window_id = window_id(&dlg); // Every other dialog fits the window (#781): its options scroll when they don't, and the title // bar and the button row stay outside the scroll area. The room they take is measured on the @@ -306,6 +310,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], Dialog::SmartRules { .. } => 680.0, Dialog::AllMetadata { .. } => 620.0, + Dialog::Print => 1100.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, Dialog::ManageEffects { .. } => 560.0, @@ -313,7 +318,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }) .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); let r = ui.add( @@ -463,7 +468,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let rules = app.session.view_rules(); - let n = app.session.catalog.query(&rules, &Default::default()).len(); + // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame + let now = (app.session.clock)(); + let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; + let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { + lightcraft_catalog::rules::set_now(Some(now.clone())); + app.session.catalog.query(&rules, &Default::default()).len() + }); ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label)); ui.label( egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) @@ -471,7 +482,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); field(ui, "Change", |ui| { ui.spacing_mut().item_spacing.x = 4.0; for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { @@ -555,7 +566,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::Rename { template, start } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); let template_id = egui::Id::new("rename-template"); let tags_open = field(ui, "Template", |ui| { @@ -637,7 +648,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::RenameKeyword { from, to } => { - let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); + let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { + app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() + }); if dialog_field(ui, "field:keywordName", to, "New name") { confirm = true; } @@ -653,11 +666,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let options: Vec = app - .session - .catalog - .keyword_suggestions(from, into, 12) - .into_iter() + .caches + .suggestions(&app.session.catalog, from, into, 12) + .iter() .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .cloned() .collect(); ui.horizontal_wrapped(|ui| { for k in options { @@ -677,7 +690,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } // as in Lightroom, the option is only there when something is selected - if !*folder && !app.session.targets(&json!({})).is_empty() { + if !*folder && !app.session.selected().is_empty() { ui.add_space(6.0); let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); crate::widgets::register(ui.ctx(), "check:albumIncludeSelected", r.rect); @@ -696,7 +709,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), Dialog::PasteSettings { groups } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label( egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n)) .color(t.text_dim), @@ -707,19 +720,19 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::ContactSheet { options } => { options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); - let n = app.session.targets(&json!({})).len(); - ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows))); + let n = app.session.selected().len(); + ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); field(ui, "Paper", |ui| { crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { ui.selectable_value(&mut options.paper, "a4".into(), "A4"); - ui.selectable_value(&mut options.paper, "letter".into(), "Letter"); + ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); }); }); - ui.checkbox(&mut options.landscape, "Landscape"); + ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); field(ui, "Columns", |ui| { ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); }); field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); }); - ui.checkbox(&mut options.captions, "Filename captions"); - ui.label("150 dpi · sRGB · current edits · fit without cropping"); + ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); + ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); } Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { @@ -789,8 +802,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { opts.bit_depth = Some(bd); } if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { - let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"; - ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip); + let tip = crate::i18n::tr("Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"); + ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); if opts.hdr && !opts.hdr_output() { ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); } @@ -1267,11 +1280,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::theme::font_credits(app.chinese_font.is_some()) )); ui.add_space(10.0); - let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE)) - .fill(t.accent) - .min_size(egui::vec2(260.0, 34.0)); - let r = ui.add(discord).on_hover_text(crate::links::DISCORD); - crate::widgets::register(ui.ctx(), "button:aboutDiscord", r.rect); + let r = crate::button::Button::new("Join the ArtCraft Discord") + .primary() + .large() + .min_width(260.0) + .widget("button:aboutDiscord") + .tooltip(crate::links::DISCORD) + .show(ui); if r.clicked() { let _ = crate::links::open(app, crate::links::DISCORD); } @@ -1290,19 +1305,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), + Dialog::Print => crate::print_view::body(app, ui, &t), }); ui.add_space(4.0); - ui.horizontal(|ui| { + // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with + // the accent when it is the dialog's primary action. The row spans the width the window + // gives its content this frame (its default width, or its content's if wider), so it + // reaches the edge without widening the window. + let row_w = ui.available_width().max(ui.min_rect().width()).max(1.0); + let row_h = ui.spacing().interact_size.y; + ui.allocate_ui_with_layout(egui::vec2(row_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that // only shows something (one Close, no Cancel / OK) let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); - let informational = unusable_model - || matches!(dlg, Dialog::LightroomResult { first: None, .. }) - || matches!( - dlg, - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew - | Dialog::ManageEffects { .. } - ); + let informational = unusable_model || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || only_shows(&dlg); let sam = &app.session.segmenter; let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); // no download location in this build: nothing to offer but the manual install @@ -1313,13 +1329,6 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::LightroomResult { .. } => "Close", _ => "Cancel", }; - if !informational { - let r = ui.button(crate::i18n::tr(cancel)); - crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); - if r.clicked() { - close = true; - } - } let add_label; let ok = match &dlg { Dialog::Import { opts } => { @@ -1331,6 +1340,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", Dialog::ContactSheet { .. } => "Choose PDF…", Dialog::SlideshowExport { .. } => "Export…", + Dialog::Print => "Print to PDF…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", Dialog::DenoiseModel { .. } => "Install", @@ -1371,10 +1381,32 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ) // smart-album rules that can't mean anything wait to be fixed && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty())); - let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); - if let Some(r) = &r { - crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); + let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; + use crate::button::{Button, Kind}; + // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a + // dialog to the one it opens) + let kind = std::mem::discriminant(&dlg); + let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); + let ok_button = Button::new(ok) + .kind(if primary { Kind::Primary } else { Kind::Secondary }) + .enabled(can_confirm) + .widget("button:dialogOk") + .id_salt(kind) + .min_width(72.0); + // (Cancel first, then the confirm: the keyboard goes through them as the eye does) + let gap = ui.spacing().item_spacing.x; + let mut buttons_w = 0.0; + if !informational { + buttons_w += cancel_button.desired_size(ui).x + gap; } + if !ok.is_empty() { + buttons_w += ok_button.desired_size(ui).x; + } + ui.add_space((ui.available_width() - buttons_w).max(0.0)); + if !informational && cancel_button.show(ui).clicked() { + close = true; + } + let r = (!ok.is_empty()).then(|| ok_button.show(ui)); if r.is_some_and(|r| r.clicked()) { if informational { close = true; @@ -1419,6 +1451,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::Export { .. } | Dialog::ContactSheet { .. } | Dialog::SlideshowExport { .. } + | Dialog::Print | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } @@ -1846,6 +1879,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("app.contactSheet", json!(options)), Dialog::SlideshowExport { settings } => app.run("app.slideshowExport", json!(settings)), + Dialog::Print => app.run("app.print", json!({})), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { @@ -2216,3 +2250,36 @@ mod watermark_position_tests { } } } + +/// How a dialog's confirm button looks: [`ConfirmStyle::Primary`] (the accent's fill) when it +/// makes or brings in something, plain when it deletes or removes (never the eye-catching +/// default) or when the dialog only shows something (its Close). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmStyle { + Primary, + Plain, +} + +pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { + match dlg { + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => { + ConfirmStyle::Plain + } + _ if only_shows(dlg) => ConfirmStyle::Plain, + _ => ConfirmStyle::Primary, + } +} + +/// A dialog that only shows something: one Close, nothing to confirm or cancel. +fn only_shows(dlg: &Dialog) -> bool { + matches!( + dlg, + Dialog::About + | Dialog::Shortcuts + | Dialog::Settings { .. } + | Dialog::SystemInfo { .. } + | Dialog::AllMetadata { .. } + | Dialog::WhatsNew + | Dialog::ManageEffects { .. } + ) +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index d67f35cc4..ded1c0bd0 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -165,7 +165,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim)); let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); - let r = crate::widgets::dropdown(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label); + // a long name is cut short before Browse Profiles: a wider row would widen the whole panel's + // content, and the sliders below would run past its edge + let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; + let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( @@ -701,9 +704,13 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } let mut clicked = resp.clicked(); + // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep + // clear of it, the left one too so they stay symmetric (#1640) + let scroll = ui.spacing().scroll; + let inset = 20.0 + scroll.bar_width + scroll.bar_outer_margin; for (name, x, label, clipped) in [ - ("shadows", plot.left() + 20.0, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), - ("highlights", plot.right() - 20.0, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + ("shadows", plot.left() + inset, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), + ("highlights", plot.right() - inset, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), ] { let rect = Rect::from_center_size(pos2(x, plot.top() + 14.0), vec2(24.0, 22.0)); let response = ui.interact(rect, ui.id().with(("histogram-clipping", name)), Sense::click()); diff --git a/crates/ui-egui/src/panels/empty_library.rs b/crates/ui-egui/src/panels/empty_library.rs new file mode 100644 index 000000000..d420ee1a0 --- /dev/null +++ b/crates/ui-egui/src/panels/empty_library.rs @@ -0,0 +1,76 @@ +//! A library with no photos yet: what the grid and the Detail view show instead of a dead end, +//! with Import Photos… and Import Lightroom Catalog… as buttons, and the view it opens on. + +use egui::Rect; +use serde_json::json; + +use super::EmptyAction; +use crate::LightcraftApp; +use crate::shortcuts::Keymap; +use crate::state::ViewMode; + +/// The library holds no photos: none, or only ones in Recently Deleted or browsed under Local. +pub fn is_empty(session: &lightcraft_engine::Session) -> bool { + !session.catalog.photos().any(|p| p.in_library()) +} + +/// What is shown is the empty library: not a folder browsed under Local nor Recently Deleted, +/// whose photos aren't in the library but are on screen. +pub fn shown(session: &lightcraft_engine::Session) -> bool { + use lightcraft_engine::LibrarySource; + !matches!(session.source, LibrarySource::Folder | LibrarySource::RecentlyDeleted) && is_empty(session) +} + +/// At launch, an empty library opens on a grid with the sidebar, where the way in is (Detail had +/// only "Choose a photo in the grid" to say). Square Grid stays as it is; a library with photos +/// opens as left. The desktop host calls this before it notes what its settings file holds, so +/// the switch isn't saved for its own sake. +pub fn open_on_empty_library(app: &mut LightcraftApp) { + if is_empty(&app.session) { + if app.ui.view != ViewMode::SquareGrid { + app.ui.view = ViewMode::PhotoGrid; + } + app.ui.left_panel = true; + } +} + +/// The line under the buttons: dropping works anywhere in the window, and the shortcut is the +/// one Import Photos… is bound to now, written as menus write it. +pub fn hint(keymap: &Keymap, mac: bool) -> String { + // the browser build reads dropped files, not folders + let drop = if cfg!(target_arch = "wasm32") { + crate::i18n::tr("or drop photos into this window") + } else { + crate::i18n::tr("or drop photos and folders into this window") + }; + match crate::shortcuts::shortcut_of(keymap, "file.addPhotos") { + Some(sc) => format!("{drop} · {}", crate::menubar::shortcut_text(sc, mac)), + None => drop.to_string(), + } +} + +const IMPORT_PHOTOS: EmptyAction = EmptyAction { command: "file.addPhotos", label: "Import Photos…", widget: "button:emptyImportPhotos" }; +const IMPORT_LIGHTROOM: EmptyAction = + EmptyAction { command: "file.importLightroom", label: "Import Lightroom Catalog…", widget: "button:emptyImportLightroom" }; + +/// The empty library's message and buttons, centred in `rect`. A click runs the command as its +/// menu item does; a refusal shows as a toast. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect) { + // the browser build can't read a Lightroom catalog from disk: only offer what works there + let actions: &[EmptyAction] = if cfg!(target_arch = "wasm32") { &[IMPORT_PHOTOS] } else { &[IMPORT_PHOTOS, IMPORT_LIGHTROOM] }; + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let hint = hint(&app.ui.settings.keymap, mac); + let clicked = super::empty_state( + ui, + rect, + "Your library is empty", + "Photos you import show up here. Nothing is copied or moved unless you choose to.", + actions, + Some(&hint), + ); + if let Some(command) = clicked + && let Err(e) = app.run(command, json!({})) + { + app.toast_error(ui.ctx(), e); + } +} diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 1aaddbeea..2893344a0 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -192,6 +192,8 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); } else if app.session.source == lightcraft_engine::LibrarySource::Folder { super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local"); + } else if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, ui.max_rect()); } else { super::empty_message(ui, ui.max_rect(), "No photos", "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here"); } @@ -1090,7 +1092,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); - if local_only(app, &app.session.targets(&serde_json::Value::Null)) { + if local_only(app, &app.session.selected()) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); register(ui.ctx(), "button:contextAddToAlbum", add.rect); @@ -1196,7 +1198,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("develop.reset", json!({})); } crate::widgets::menu_button(ui, crate::i18n::tr("Photo Merge"), |ui| { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { if ui.add_enabled(n >= 2, egui::Button::new(crate::i18n::tr(label))).clicked() { let _ = app.run(id, json!({})); diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 6fa21969a..640f07f4c 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -77,7 +77,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(crate::i18n::tr(none)).color(t.text_dim)); }); } - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let ticks = app.caches.keyword_ticks(&app.session.catalog, &selection); for r in &rows { row(app, ui, r, &selection, &ticks, filter.trim().is_empty()); @@ -621,13 +621,13 @@ mod tests { "Export Keywords…", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant", "cs", "pl"] { + for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ja", "zh-hans", "zh-hant", "pl", "cs"] { let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); let catalog: std::collections::HashMap = serde_json::from_str(&text).unwrap(); for label in labels { assert!(catalog.contains_key(label), "{code}: “{label}”"); } } - assert_eq!(Locale::ALL.len(), 12, "a language added: list its file above"); + assert_eq!(Locale::ALL.len(), 13, "a language added: list its file above"); } } diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index b1f1e919e..bad2a2f43 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -30,7 +30,7 @@ pub fn view_switch(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// exported files will carry. One only some of the photos have is marked with an asterisk. pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let export = app.ui.keywording_view == crate::state::KeywordingView::WillExport; let names = app.caches.keyword_names(&app.session.catalog, &selection, export); if names.is_empty() { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index ee3d7eae9..d043e42a8 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1565,7 +1565,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { ui.separator(); } if !a.folder && !a.is_smart() { - let local_only = super::grid::local_only(app, &app.session.targets(&serde_json::Value::Null)); + let local_only = super::grid::local_only(app, &app.session.selected()); let b = ui .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) .on_disabled_hover_text(super::grid::albums_hint()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 960550922..2fcb36663 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -9,6 +9,7 @@ pub mod denoise; pub mod detail; pub mod dialogs; pub mod edit; +pub mod empty_library; pub mod faces; pub mod filterbar; pub mod grid; @@ -108,3 +109,72 @@ pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) { p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, crate::i18n::tr(title), t.semibold(18.0), t.text_label); p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, crate::i18n::tr(body), t.font(13.0), t.text_dim); } + +/// A button under an empty state's message: the command it runs, its label, and the id it is +/// registered under (`button:…`) for scripts and tests. +pub struct EmptyAction { + pub command: &'static str, + pub label: &'static str, + pub widget: &'static str, +} + +/// [`empty_message`] with the next step under it: a row of buttons (the first is the primary +/// one, filled with the accent) and an optional dimmed hint line below them. The block is +/// centred in `rect` as one. Returns the command of the button clicked this frame. +pub fn empty_state(ui: &mut egui::Ui, rect: Rect, title: &str, body: &str, actions: &[EmptyAction], hint: Option<&str>) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let wrap = (rect.width() - 48.0).max(80.0); + let centred = |ui: &egui::Ui, text: &str, font: egui::FontId, color: egui::Color32| { + let mut job = egui::text::LayoutJob::simple(text.to_string(), font, color, wrap); + job.halign = egui::Align::Center; + ui.painter().layout_job(job) + }; + let title = centred(ui, crate::i18n::tr(title), t.semibold(18.0), t.text_label); + let body = centred(ui, crate::i18n::tr(body), t.font(13.0), t.text_dim); + let hint = hint.map(|h| centred(ui, h, t.font(12.0), t.text_dim)); + // the buttons' widths, and whether they fit side by side (else one above the other) + const BUTTON_H: f32 = 30.0; + const GAP: f32 = 8.0; + let widths: Vec = + actions.iter().map(|a| ui.painter().layout_no_wrap(crate::i18n::tr(a.label).to_string(), t.semibold(13.0), t.text).size().x + 32.0).collect(); + let row_w = widths.iter().sum::() + GAP * widths.len().saturating_sub(1) as f32; + let side_by_side = row_w <= wrap; + let buttons_h = match (actions.len(), side_by_side) { + (0, _) => 0.0, + (_, true) => BUTTON_H, + (n, false) => n as f32 * BUTTON_H + (n - 1) as f32 * GAP, + }; + let gap_above_buttons = if actions.is_empty() { 0.0 } else { 20.0 }; + let hint_h = hint.as_ref().map_or(0.0, |g| 12.0 + g.size().y); + let block = title.size().y + 6.0 + body.size().y + gap_above_buttons + buttons_h + hint_h; + let top = rect.center().y - block / 2.0; + let p = ui.painter().clone(); + let body_top = top + title.size().y + 6.0; + let buttons_top = body_top + body.size().y + gap_above_buttons; + p.galley(pos2(rect.center().x, top), title, t.text_label); + p.galley(pos2(rect.center().x, body_top), body, t.text_dim); + let mut clicked = None; + let mut x = rect.center().x - row_w / 2.0; + for (i, (a, w)) in actions.iter().zip(&widths).enumerate() { + let r = if side_by_side { + let r = Rect::from_min_size(pos2(x, buttons_top), vec2(*w, BUTTON_H)); + x += w + GAP; + r + } else { + let w = widths.iter().copied().fold(0.0, f32::max).min(wrap); + Rect::from_min_size(pos2(rect.center().x - w / 2.0, buttons_top + i as f32 * (BUTTON_H + GAP)), vec2(w, BUTTON_H)) + }; + let label = egui::RichText::new(crate::i18n::tr(a.label)).font(t.semibold(13.0)); + let button = + if i == 0 { egui::Button::new(label.color(egui::Color32::WHITE)).fill(t.accent) } else { egui::Button::new(label.color(t.text)) }; + let resp = ui.put(r, button); + crate::widgets::register(ui.ctx(), a.widget, resp.rect); + if resp.clicked() { + clicked = Some(a.command); + } + } + if let Some(g) = hint { + p.galley(pos2(rect.center().x, buttons_top + buttons_h + 12.0), g, t.text_dim); + } + clicked +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6a363278a..9f3a0af89 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -15,6 +15,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |ui| { + // (taken on every pass the panel is drawn; one from earlier than the pass before is forgotten) + let held = held_offset(ui.ctx()); let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection if app.ui.right == RightPanel::Keywords { @@ -30,10 +32,28 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; } let r = ui.max_rect(); - super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + // every tab (Edit, Info, Versions…) says it: nothing to show until photos come in + if super::empty_library::shown(&app.session) { + super::empty_message(ui, r, "No photos yet", "Import photos to get started"); + } else { + super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + } return; }; - egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { + let mut area = egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]); + if let Some(offset) = held { + area = area.vertical_scroll_offset(offset); + } + // (where the pass starts: by its end egui may have pulled a shrunken panel's offset back) + let area_id = ui.data(|d| d.get_temp::(egui::Id::new(AREA))); + let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.offset.y)); + ui.data_mut(|d| match start { + Some(o) => { + d.insert_temp(egui::Id::new(OFFSET), o); + } + None => d.remove::(egui::Id::new(OFFSET)), + }); + let scrolled = area.show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; match app.ui.right { RightPanel::Edit => super::edit::show(app, ui, id), @@ -49,9 +69,28 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::None => {} } }); + ui.data_mut(|d| d.insert_temp(egui::Id::new(AREA), scrolled.id)); }); } +/// The panel's scroll area, its offset as the last pass started, and how far +/// [`hold_list_in_place`] asks it to move. +const AREA: &str = "right-scroll-id"; +const OFFSET: &str = "right-scroll-offset"; +const HOLD: &str = "right-scroll-hold"; + +/// Where [`hold_list_in_place`] holds the panel: its offset as the pass before started, moved by as much as +/// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list +/// in place (a scroll asked for during a pass only lands a frame later). +fn held_offset(ctx: &egui::Context) -> Option { + let (grew, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::new(HOLD)))?; + if asked.checked_add(1) != Some(ctx.cumulative_pass_nr()) { + return None; + } + let offset = ctx.data(|d| d.get_temp::(egui::Id::new(OFFSET)))?; + Some((offset + grew).max(0.0)).filter(|o| o.is_finite()) +} + pub fn header(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); @@ -876,9 +915,11 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.session.catalog.photo(id).is_none() { return; } + let top = ui.cursor().top(); if fold_header(app, ui, "keywording", "Keywords") { keywording(app, ui); } + hold_list_in_place(ui, top); super::keyword_list::show(app, ui); } @@ -890,7 +931,7 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { let kid = egui::Id::new("kw-input"); let mut text = ui.data_mut(|d| d.get_temp::(kid).unwrap_or_default()); // the keywords every selected photo has: those only some have are still offered and suggested - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let have: Vec = app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); // while the box has the cursor it offers the library's keywords for what is being typed, @@ -1063,11 +1104,40 @@ fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at +/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it +/// a long tree slides away from under the pointer by a row of chips each time. It holds only while +/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and +/// while the change does not come from the box itself (the pointer on it, or typing in its field). +fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { + let height = ui.cursor().top() - top; + let key = egui::Id::new("kw-box-height"); + let pass = ui.ctx().cumulative_pass_nr(); + // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) + let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); + ui.data_mut(|d| d.insert_temp(key, (height, pass))); + let Some(before) = before else { return }; + let grew = height - before; + if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { + return; + } + // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) + let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); + let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); + let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); + if on_box || typing { + return; + } + ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), (grew, pass))); + // (drawn again at once at the new offset, so the list never shows a frame out of place) + ui.ctx().request_discard("the Keyword List holds its place"); +} + /// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the /// selected photos; "Save as Set…" keeps the current nine under a name. fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); let t = Tokens::get(ui.ctx()); let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index c08f753f5..649c8aa15 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -457,7 +457,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); let used = app.session.media.rendered.disk().map(|d| d.size()); row(ui, t, crate::i18n::tr("In use"), |ui| { - ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text)); + ui.label( + RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| crate::i18n::tr("memory only").to_string())) + .color(t.text), + ); let r = ui.button(crate::i18n::tr("Clear Cache")); register(ui.ctx(), "button:settingsClearCache", r.rect); if r.clicked() { diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 66889af79..7e40af7d5 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -60,12 +60,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - // room for the filter icon and the six icons on the right - let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); + // room for the filter icon and the seven icons on the right + // (the field is centred, so each icon on the right takes its width from both sides) + let mut sw = 640.0f32.min(full.width() - 620.0).max(190.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; - sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); + sw = sw.min(2.0 * (full.center().x - menus_right)).max(190.0); } let search_left = full.center().x - sw / 2.0; let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); @@ -172,11 +173,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ) }); let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); + // Import Photos… beside Export, always one click away, with the key it is bound to now + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { + Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), + None => crate::i18n::tr("Import Photos…").to_string(), + }; for (id, icon, tip, cmd) in [ ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), ("cloud", Icon::Cloud, cloud_tip, ""), ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), ("share", Icon::Share, "Export", "dialog.export"), + ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), ("bell", Icon::Bell, "Activity", "panel.activity"), ] { let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); @@ -198,8 +206,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); register(ui.ctx(), "indicator:unsaved", r); } - if resp.clicked() && !cmd.is_empty() { - let _ = app.run(cmd, json!({})); + // a refusal says why (Import while a Lightroom catalog is coming in) + if resp.clicked() + && !cmd.is_empty() + && let Err(e) = app.run(cmd, json!({})) + { + app.toast_error(ui.ctx(), e); } x -= 40.0; } diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs new file mode 100644 index 000000000..71e836bc8 --- /dev/null +++ b/crates/ui-egui/src/print_view.rs @@ -0,0 +1,913 @@ +//! The Print dialog (File ▸ Print…): settings on the left, the pages drawn to scale on the right. +//! +//! Every change goes through a `print.*` command ([`lightcraft_engine::cmd`]); this file only draws +//! the session's print ([`lightcraft_engine::Session::print`]) and turns pointer input into +//! commands: dragging a cell is `print.cell.move`, its corner handle `print.cell.resize`, with +//! the guides' snapping done by the command. Widgets register under `print:*`, `button:print*` +//! and `drag:print*` ids for the control channel and the headless tests. +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_engine::export::ExportOptions; +use lightcraft_engine::print::compose::{self, place}; +use lightcraft_engine::print::layout::{self, Snap, Style, Unit}; +use lightcraft_engine::print::template::PrintSettings; +use lightcraft_engine::print_color::{ManagedSpace, PrintColorSettings, PrintIntent, PrintMedia, PrintProfile, PrintSharpen}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::{register, text_button}; + +/// Width of the settings column. +const SETTINGS_W: f32 = 290.0; +/// Handle for resizing the selected cell, screen points. +const HANDLE: f32 = 12.0; +/// Snap distance while dragging, screen points. +const SNAP_PX: f32 = 8.0; +/// Cell sizes offered as buttons, inches (width, height). +const CELL_SIZES: &[(f64, f64)] = &[(2.0, 2.0), (2.5, 3.5), (3.5, 5.0), (4.0, 5.0), (4.0, 6.0), (5.0, 7.0), (8.0, 10.0), (3.0, 3.0)]; + +/// A drag of a cell in progress: the cell's rectangle when it began and how far the pointer went. +#[derive(Clone, Debug)] +pub struct Drag { + cell: u32, + origin: layout::Rect, + resize: bool, + /// Where the pointer was pressed; the distance dragged is measured from it, so the first few + /// points egui spends deciding between a click and a drag are not lost. + press: Pos2, + total: Vec2, +} + +/// What the Print dialog remembers between frames (not saved). +#[derive(Clone, Debug, Default)] +pub struct PrintUi { + /// The page being shown, zero-based. + pub page: usize, + /// The selected cell. + pub selected: Option, + drag: Option, + /// The name typed for Save Template. + pub template_name: String, + /// Size of the next cell added, inches. + pub new_cell: Option<(f64, f64)>, +} + +fn run(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Option { + match app.run(id, params) { + Ok(v) => Some(v), + Err(e) => { + app.toast(ctx, e); + None + } + } +} + +fn patch(app: &mut LightcraftApp, ctx: &egui::Context, params: Value) { + run(app, ctx, "print.set", params); +} + +fn heading(ui: &mut Ui, t: &Tokens, text: &str) { + ui.add_space(8.0); + ui.label(egui::RichText::new(crate::i18n::tr(text)).font(t.semibold(12.0)).color(t.text)); + ui.add_space(2.0); +} + +fn row(ui: &mut Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(vec2(86.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +fn decimals(unit: Unit) -> usize { + match unit { + Unit::Inches | Unit::Centimeters => 2, + Unit::Millimeters => 1, + Unit::Points => 0, + } +} + +fn fmt_len(inches: f64, unit: Unit) -> String { + format!("{:.*} {}", decimals(unit), unit.from_inches(inches), unit.label()) +} + +/// A length field in the ruler's unit; the new value in inches when it changed. +fn length(ui: &mut Ui, id: &str, inches: f64, unit: Unit, min_in: f64, max_in: f64) -> Option { + let mut v = unit.from_inches(inches); + let r = ui.add( + egui::DragValue::new(&mut v) + .range(unit.from_inches(min_in)..=unit.from_inches(max_in)) + .speed(unit.per_inch() * 0.01) + .max_decimals(decimals(unit)) + .suffix(format!(" {}", unit.label())), + ); + register(ui.ctx(), format!("drag:{id}"), r.rect); + r.changed().then(|| unit.to_inches(v)) +} + +fn check(ui: &mut Ui, id: &str, label: &str, value: bool) -> Option { + let mut v = value; + let r = ui.checkbox(&mut v, crate::i18n::tr(label)); + register(ui.ctx(), format!("check:{id}"), r.rect); + r.changed().then_some(v) +} + +fn choice(ui: &mut Ui, id: &str, options: &[(V, &str)], current: V) -> Option { + let mut out = None; + ui.spacing_mut().item_spacing.x = 3.0; + for (i, (v, l)) in options.iter().enumerate() { + if text_button(ui, &format!("{id}-{i}"), l, current == *v).clicked() && current != *v { + out = Some(*v); + } + } + out +} + +fn colour(ui: &mut Ui, id: &str, rgb: [u8; 3]) -> Option<[u8; 3]> { + let mut c = rgb; + let r = ui.color_edit_button_srgb(&mut c); + register(ui.ctx(), format!("color:{id}"), r.rect); + (c != rgb).then_some(c) +} + +fn paper_label(name: &str) -> String { + match name { + "a3" | "a4" | "a5" => name.to_uppercase(), + "letter" => "Letter".into(), + "legal" => "Legal".into(), + "tabloid" => "Tabloid".into(), + "custom" => "Custom".into(), + other => other.replace('x', " × ") + " in", + } +} + +/// The dialog's body: the settings column and the page preview side by side. +pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { + let ctx = ui.ctx().clone(); + let screen = ctx.content_rect(); + let width = (screen.width() - 80.0).clamp(640.0, 1180.0); + let height = (screen.height() - 190.0).clamp(280.0, 780.0); + let mut pu = std::mem::take(&mut app.ui.print); + let st = app.session.print.clone(); + // (no `id`/`ids` given: the selection, which never fails to parse) + let ids = app.session.targets(&json!({})).unwrap_or_default(); + // the page shown stays inside the print + let pages = st.layout.page_count(ids.len()); + pu.page = pu.page.min(pages.saturating_sub(1)); + if pu.selected.is_some_and(|c| st.layout.cell(c).is_none_or(|c| c.page as usize != pu.page)) && pu.drag.is_none() { + pu.selected = pu.selected.filter(|c| st.layout.cell(*c).is_some()); + } + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(SETTINGS_W, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(SETTINGS_W, height)); + egui::ScrollArea::vertical().id_salt("print-settings").max_height(height).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(SETTINGS_W - 14.0); + settings(app, ui, t, &mut pu, &st, ids.len()); + }); + }); + // (a `separator` would take the dialog's whole free height) + let (line, _) = ui.allocate_exact_size(vec2(1.0, height), Sense::hover()); + ui.painter().line_segment([line.center_top(), line.center_bottom()], Stroke::new(1.0, t.divider)); + let w = (width - SETTINGS_W - 24.0).max(300.0); + ui.allocate_ui_with_layout(vec2(w, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(w, height)); + preview(app, ui, t, &mut pu, &st, &ids, pages); + }); + }); + app.ui.print = pu; +} + +fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; + ui.spacing_mut().item_spacing.y = 5.0; + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); + + heading(ui, t, "Templates"); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:printTemplateName", &mut pu.template_name) + .hint(crate::i18n::tr("Template name")) + .width(140.0) + .show(ui); + if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() { + run(app, &ctx, "print.template.save", json!({"name": pu.template_name})); + } + }); + let names: Vec = app.session.print_templates.iter().map(|t| t.name.clone()).collect(); + for (i, name) in names.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).color(t.text_label)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() { + run(app, &ctx, "print.template.delete", json!({"name": name})); + } + if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() { + run(app, &ctx, "print.template.apply", json!({"name": name})); + pu.page = 0; + pu.selected = None; + } + }); + }); + } + + heading(ui, t, "Layout Style"); + let styles = + [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; + ui.horizontal_wrapped(|ui| { + if let Some(s) = choice(ui, "printStyle", &styles, l.style) { + patch(app, &ctx, json!({"layout": {"style": s}})); + pu.page = 0; + } + }); + + heading(ui, t, "Image Settings"); + let im = &st.image; + if let Some(v) = check(ui, "printZoomFill", "Zoom to Fill Frame", im.zoom_to_fill) { + patch(app, &ctx, json!({"image": {"zoomToFill": v}})); + } + if let Some(v) = check(ui, "printRotateFit", "Rotate to Fit", im.rotate_to_fit) { + patch(app, &ctx, json!({"image": {"rotateToFit": v}})); + } + if l.style.is_package() { + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printBorder", "Photo Border", im.border_enabled) { + patch(app, &ctx, json!({"image": {"borderEnabled": v}})); + } + if let Some(w) = length(ui, "printBorderWidth", im.border_width, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"image": {"borderWidth": w}})); + } + if let Some(c) = colour(ui, "printBorderColor", im.border_color) { + patch(app, &ctx, json!({"image": {"borderColor": c}})); + } + }); + } + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printStroke", "Inner Stroke", im.stroke_enabled) { + patch(app, &ctx, json!({"image": {"strokeEnabled": v}})); + } + let mut pt = im.stroke_width_pt; + let r = ui.add(egui::DragValue::new(&mut pt).range(0.0..=36.0).speed(0.05).max_decimals(2).suffix(" pt")); + register(ui.ctx(), "drag:printStrokeWidth", r.rect); + if r.changed() { + patch(app, &ctx, json!({"image": {"strokeWidthPt": pt}})); + } + if let Some(c) = colour(ui, "printStrokeColor", im.stroke_color) { + patch(app, &ctx, json!({"image": {"strokeColor": c}})); + } + }); + + heading(ui, t, "Layout"); + match l.style { + Style::SingleImage => { + ui.label(egui::RichText::new(crate::i18n::tr("One photo fills each page inside the margins.")).color(t.text_dim)); + } + Style::ContactSheet => { + let g = &l.grid; + row(ui, t, "Grid", |ui| { + let (mut c, mut r) = (g.columns, g.rows); + let a = ui.add(egui::DragValue::new(&mut c).range(1..=layout::MAX_GRID).suffix(" cols")); + register(ui.ctx(), "drag:printColumns", a.rect); + let b = ui.add(egui::DragValue::new(&mut r).range(1..=layout::MAX_GRID).suffix(" rows")); + register(ui.ctx(), "drag:printRows", b.rect); + if a.changed() || b.changed() { + patch(app, &ctx, json!({"layout": {"grid": {"columns": c, "rows": r}}})); + } + }); + row(ui, t, "Spacing", |ui| { + if let Some(v) = length(ui, "printSpacingX", g.spacing_x, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingX": v}}})); + } + if let Some(v) = length(ui, "printSpacingY", g.spacing_y, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingY": v}}})); + } + }); + if let Some(v) = check(ui, "printKeepSquare", "Keep Square", g.keep_square) { + patch(app, &ctx, json!({"layout": {"grid": {"keepSquare": v}}})); + } + } + Style::PicturePackage | Style::CustomPackage => { + ui.label(egui::RichText::new(crate::i18n::tr("Add a cell")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (i, (w, h)) in CELL_SIZES.iter().enumerate() { + if text_button(ui, &format!("printAddCell-{i}"), &format!("{} × {}", trim(*w), trim(*h)), false).clicked() { + add_cell(app, &ctx, pu, *w, *h); + } + } + }); + let (mut w, mut h) = pu.new_cell.unwrap_or((3.0, 2.0)); + row(ui, t, "Custom size", |ui| { + if let Some(v) = length(ui, "printNewCellW", w, unit, layout::MIN_CELL_IN, 30.0) { + w = v; + } + if let Some(v) = length(ui, "printNewCellH", h, unit, layout::MIN_CELL_IN, 30.0) { + h = v; + } + }); + pu.new_cell = Some((w, h)); + ui.horizontal(|ui| { + if text_button(ui, "printAddCustom", "Add Cell", false).clicked() { + add_cell(app, &ctx, pu, w, h); + } + if l.style == Style::PicturePackage && text_button(ui, "printAutoLayout", "Auto Layout", false).clicked() { + run(app, &ctx, "print.layout.autoLayout", json!({})); + pu.page = 0; + } + }); + ui.horizontal(|ui| { + if text_button(ui, "printNewPage", "New Page", false).clicked() + && let Some(r) = run(app, &ctx, "print.layout.newPage", json!({})) + { + pu.page = r["page"].as_u64().unwrap_or(0) as usize; + } + if text_button(ui, "printClear", "Clear Layout", false).clicked() { + run(app, &ctx, "print.layout.clear", json!({})); + pu.page = 0; + pu.selected = None; + } + }); + row(ui, t, "Gap", |ui| { + if let Some(v) = length(ui, "printGap", l.spacing, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"spacing": v}})); + } + }); + } + } + + heading(ui, t, "Rulers, Grid & Guides"); + let g = &st.guides; + if let Some(v) = check(ui, "printRulers", "Show Rulers", g.show_rulers) { + patch(app, &ctx, json!({"guides": {"showRulers": v}})); + } + row(ui, t, "Units", |ui| { + let units = [(Unit::Inches, "in"), (Unit::Centimeters, "cm"), (Unit::Millimeters, "mm"), (Unit::Points, "pt")]; + if let Some(u) = choice(ui, "printUnits", &units, g.units) { + patch(app, &ctx, json!({"guides": {"units": u}})); + } + }); + row(ui, t, "Snap", |ui| { + if let Some(s) = choice(ui, "printSnap", &[(Snap::Off, "Off"), (Snap::Cells, "Cells"), (Snap::Grid, "Grid")], g.snap) { + patch(app, &ctx, json!({"guides": {"snap": s}})); + } + }); + row(ui, t, "Grid size", |ui| { + if let Some(v) = length(ui, "printGridSize", g.grid_size, unit, 0.05, 10.0) { + patch(app, &ctx, json!({"guides": {"gridSize": v}})); + } + }); + for (id, label, on, key) in [ + ("printShowMargins", "Page Margins", g.show_margins, "showMargins"), + ("printShowBleed", "Page Bleed", g.show_bleed, "showBleed"), + ("printShowGrid", "Page Grid", g.show_page_grid, "showPageGrid"), + ("printShowCells", "Image Cells", g.show_cells, "showCells"), + ("printShowDims", "Dimensions", g.show_dimensions, "showDimensions"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"guides": {(key): v}})); + } + } + + heading(ui, t, "Page Setup"); + row(ui, t, "Paper", |ui| { + let current = if l.page.paper.eq_ignore_ascii_case("custom") { "custom".to_string() } else { l.page.paper.to_ascii_lowercase() }; + let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(¤t)).show_ui(ui, |ui| { + for name in layout::paper_names().chain(std::iter::once("custom")) { + if ui.selectable_label(current == name, paper_label(name)).clicked() { + patch(app, &ctx, json!({"layout": {"page": {"paper": name}}})); + } + } + }); + register(ui.ctx(), "combo:printPaper", c.response.rect); + }); + if l.page.paper.eq_ignore_ascii_case("custom") { + row(ui, t, "Size", |ui| { + if let Some(v) = length(ui, "printCustomW", l.page.custom_width, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customWidth": v}}})); + } + if let Some(v) = length(ui, "printCustomH", l.page.custom_height, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customHeight": v}}})); + } + }); + } + row(ui, t, "Orientation", |ui| { + let o = [(layout::Orientation::Portrait, "Portrait"), (layout::Orientation::Landscape, "Landscape")]; + if let Some(v) = choice(ui, "printOrientation", &o, l.page.orientation) { + patch(app, &ctx, json!({"layout": {"page": {"orientation": v}}})); + } + }); + let m = &l.page.margins; + for (label, id, key, v) in [ + ("Left", "printMarginLeft", "left", m.left), + ("Right", "printMarginRight", "right", m.right), + ("Top", "printMarginTop", "top", m.top), + ("Bottom", "printMarginBottom", "bottom", m.bottom), + ] { + row(ui, t, label, |ui| { + if let Some(v) = length(ui, id, v, unit, 0.0, layout::MAX_MARGIN_IN) { + patch(app, &ctx, json!({"layout": {"page": {"margins": {(key): v}}}})); + } + }); + } + + heading(ui, t, "Page"); + row(ui, t, "Background", |ui| { + if let Some(c) = colour(ui, "printBackground", st.page.background) { + patch(app, &ctx, json!({"page": {"background": c}})); + } + }); + + heading(ui, t, "Print Job"); + row(ui, t, "Resolution", |ui| { + let mut ppi = st.job.ppi; + let r = ui.add(egui::DragValue::new(&mut ppi).range(72.0..=720.0).speed(1.0).max_decimals(0).suffix(" ppi")); + register(ui.ctx(), "drag:printPpi", r.rect); + if r.changed() { + patch(app, &ctx, json!({"job": {"ppi": ppi}})); + } + }); + print_job_color(app, ui, t, &st.job.color); +} + +/// Send one colour setting of the print job (the rest stay as they are). +fn patch_color(app: &mut LightcraftApp, ctx: &egui::Context, key: &str, value: Value) { + patch(app, ctx, json!({"job": {"color": {(key): value}}})); +} + +fn to_value(v: V) -> Value { + serde_json::to_value(v).unwrap_or(Value::Null) +} + +/// The Print Job panel's colour management, print adjustment, print sharpening and depth. +fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &PrintColorSettings) { + let ctx = ui.ctx().clone(); + let managed = |space| PrintProfile::Managed { space }; + let shown = match &color.profile { + PrintProfile::Managed { space: ManagedSpace::Srgb } => crate::i18n::tr("Managed by Printer (sRGB)").to_string(), + PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => crate::i18n::tr("Managed by Printer (Adobe RGB)").to_string(), + PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()), + }; + let mut pick = false; + row(ui, t, "Profile", |ui| { + let c = egui::ComboBox::from_id_salt("print-profile").width(190.0).selected_text(shown.clone()).show_ui(ui, |ui| { + for (space, label) in [(ManagedSpace::Srgb, "Managed by Printer (sRGB)"), (ManagedSpace::AdobeRgb, "Managed by Printer (Adobe RGB)")] { + let r = ui.selectable_label(color.profile == managed(space), crate::i18n::tr(label)); + register(ui.ctx(), format!("item:printProfile-{}", to_value(space).as_str().unwrap_or_default()), r.rect); + if r.clicked() && color.profile != managed(space) { + patch_color(app, &ctx, "profile", to_value(managed(space))); + } + } + if let PrintProfile::Icc { .. } = &color.profile { + let _ = ui.selectable_label(true, shown.as_str()); + } + if app.services.pick_display_profile.is_some() { + let r = ui.selectable_label(false, crate::i18n::tr("Other…")); + register(ui.ctx(), "item:printProfile-other", r.rect); + pick |= r.clicked(); + } + }); + register(ui.ctx(), "combo:printProfile", c.response.rect); + }); + if pick && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) { + patch_color(app, &ctx, "profile", json!({"kind": "icc", "path": path})); + } + row(ui, t, "Intent", |ui| { + if let Some(v) = choice(ui, "printIntent", &[(PrintIntent::Perceptual, "Perceptual"), (PrintIntent::Relative, "Relative")], color.intent) { + patch_color(app, &ctx, "intent", to_value(v)); + } + }); + ui.add_enabled_ui(color.intent == PrintIntent::Relative, |ui| { + if let Some(v) = check(ui, "printBpc", "Black Point Compensation", color.black_point_compensation) { + patch_color(app, &ctx, "blackPointCompensation", json!(v)); + } + }); + for (key, label, value) in [("brightness", "Brightness", color.brightness), ("contrast", "Contrast", color.contrast)] { + row(ui, t, label, |ui| { + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).range(-100.0..=100.0).speed(0.5).max_decimals(0)); + register(ui.ctx(), format!("drag:print{label}"), r.rect); + if r.changed() { + patch_color(app, &ctx, key, json!(v)); + } + }); + } + row(ui, t, "Sharpening", |ui| { + let options = [(PrintSharpen::Off, "Off"), (PrintSharpen::Low, "Low"), (PrintSharpen::Standard, "Standard"), (PrintSharpen::High, "High")]; + if let Some(v) = choice(ui, "printSharpen", &options, color.sharpen) { + patch_color(app, &ctx, "sharpen", to_value(v)); + } + }); + ui.add_enabled_ui(color.sharpen != PrintSharpen::Off, |ui| { + row(ui, t, "Media", |ui| { + if let Some(v) = choice(ui, "printMedia", &[(PrintMedia::Matte, "Matte"), (PrintMedia::Glossy, "Glossy")], color.media) { + patch_color(app, &ctx, "media", to_value(v)); + } + }); + }); + if let Some(v) = check(ui, "print16bit", "16 bits per channel", color.sixteen_bit) { + patch_color(app, &ctx, "sixteenBit", json!(v)); + } + let bits = if color.sixteen_bit { "16-bit" } else { "8-bit" }; + let summary = format!("PDF · {shown} · {bits} · {}", crate::i18n::tr("current edits")); + ui.label(egui::RichText::new(summary).color(t.text_dim)); +} + +fn trim(v: f64) -> String { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn add_cell(app: &mut LightcraftApp, ctx: &egui::Context, pu: &mut PrintUi, w: f64, h: f64) { + if let Some(r) = run(app, ctx, "print.cell.add", json!({"width": w, "height": h})) { + pu.selected = r["id"].as_u64().and_then(|v| u32::try_from(v).ok()); + pu.page = r["cell"]["page"].as_u64().unwrap_or(0) as usize; + } +} + +// ---- preview ------------------------------------------------------------------------------- + +/// Screen geometry of the page on show. +struct Sheet { + page: Rect, + /// Screen points per inch. + scale: f32, +} + +impl Sheet { + fn rect(&self, r: &layout::Rect) -> Rect { + Rect::from_min_size(self.page.min + vec2(r.x as f32, r.y as f32) * self.scale, vec2(r.w as f32, r.h as f32) * self.scale) + } +} + +fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, ids: &[PhotoId], pages: usize) { + let ctx = ui.ctx().clone(); + // room for the page buttons and, for a package, the selected cell's row (which may wrap) + let footer = if st.layout.style.is_package() { 104.0 } else { 44.0 }; + let size = vec2(ui.available_width(), (ui.available_height() - footer).max(120.0)); + let (area, _) = ui.allocate_exact_size(size, Sense::hover()); + let painter = ui.painter_at(area); + painter.rect_filled(area, 4.0, t.canvas); + let Ok((pw, ph)) = st.layout.page_size() else { + painter.text(area.center(), Align2::CENTER_CENTER, crate::i18n::tr("This page setup is not valid"), t.font(13.0), t.caution); + return; + }; + let g = &st.guides; + let ruler = if g.show_rulers { 22.0 } else { 0.0 }; + let inner = Rect::from_min_max(area.min + vec2(ruler, ruler), area.max).shrink(14.0); + let scale = ((inner.width() as f64 / pw).min(inner.height() as f64 / ph) as f32).max(1.0); + let sheet = Sheet { page: Rect::from_center_size(inner.center(), vec2(pw as f32, ph as f32) * scale), scale }; + register(&ctx, "print:page", sheet.page); + + painter.rect_filled(sheet.page.translate(vec2(2.0, 3.0)), 0.0, Color32::from_black_alpha(70)); + let bg = st.page.background; + painter.rect_filled(sheet.page, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); + let guide = Stroke::new(1.0, Color32::from_rgb(90, 160, 240)); + if g.show_rulers { + rulers(&painter, t, &sheet, area, pw, ph, g.units); + } + let area_in = st.layout.printable().ok(); + if g.show_bleed { + let b = 0.125; + dashed(&painter, sheet.page.expand(b as f32 * scale), Stroke::new(1.0, t.caution)); + } + if g.show_page_grid + && let Some(a) = area_in + { + let faint = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 70)); + let step = g.grid_size.max(0.05); + let mut x = 0.0; + while a.x + x <= a.right() + 1e-9 && x < 200.0 { + let sx = sheet.page.left() + (a.x + x) as f32 * scale; + painter.line_segment([pos2(sx, sheet.page.top()), pos2(sx, sheet.page.bottom())], faint); + x += step; + } + let mut y = 0.0; + while a.y + y <= a.bottom() + 1e-9 && y < 200.0 { + let sy = sheet.page.top() + (a.y + y) as f32 * scale; + painter.line_segment([pos2(sheet.page.left(), sy), pos2(sheet.page.right(), sy)], faint); + y += step; + } + } + if g.show_margins + && let Some(a) = area_in + { + dashed(&painter, sheet.rect(&layout::Rect::new(a.x, a.y, a.w, a.h)), guide); + } + + let package = st.layout.style.is_package(); + if package { + package_cells(app, ui, &painter, t, pu, st, ids, &sheet); + } else { + let plan = compose::plan_cells(&st.layout, ids).ok(); + let cells: Vec<(layout::Rect, Option)> = match plan.as_ref().and_then(|p| p.get(pu.page)) { + Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(), + None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(), + }; + for (i, (r, photo)) in cells.iter().enumerate() { + let rect = sheet.rect(r); + register(&ctx, format!("print:gridcell:{i}"), rect); + paint_cell(app, &painter, t, st, rect, *photo, &sheet, r); + } + } + + // footer: page navigation and the selected cell + ui.horizontal(|ui| { + if text_button(ui, "printPagePrev", "◀", false).clicked() && pu.page > 0 { + pu.page -= 1; + pu.selected = None; + } + let label = ui.label(egui::RichText::new(format!("{} {} / {}", crate::i18n::tr("Page"), pu.page + 1, pages)).color(t.text)); + register(ui.ctx(), "print:pageLabel", label.rect); + if text_button(ui, "printPageNext", "▶", false).clicked() && pu.page + 1 < pages { + pu.page += 1; + pu.selected = None; + } + ui.label(egui::RichText::new(fmt_len(pw, g.units) + " × " + &fmt_len(ph, g.units)).color(t.text_dim)); + if package { + ui.label(egui::RichText::new(format!("{} {}", st.layout.cells.len(), crate::i18n::tr("cells"))).color(t.text_dim)); + if st.layout.has_overlaps() { + ui.label(egui::RichText::new(crate::i18n::tr("cells overlap")).color(t.caution)); + } + } + }); + if package { + inspector(app, ui, t, pu, st); + } +} + +fn press_origin(ui: &Ui, resp: &egui::Response) -> Pos2 { + ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()).unwrap_or_default() +} + +/// Draws and handles every cell of a package page, back to front. +#[allow(clippy::too_many_arguments)] +fn package_cells( + app: &mut LightcraftApp, + ui: &mut Ui, + painter: &egui::Painter, + t: &Tokens, + pu: &mut PrintUi, + st: &PrintSettings, + ids: &[PhotoId], + sheet: &Sheet, +) { + let ctx = ui.ctx().clone(); + let first = ids.first().copied(); + let cells: Vec = st.layout.cells_on_page(pu.page as u32).cloned().collect(); + for c in &cells { + let photo = if st.layout.style == Style::CustomPackage { c.photo.map(PhotoId).or(first) } else { first }; + let rect = sheet.rect(&c.rect); + paint_cell(app, painter, t, st, rect, photo, sheet, &c.rect); + let resp = ui.interact(rect, egui::Id::new(("print-cell", c.id)), Sense::click_and_drag()); + register(&ctx, format!("print:cell:{}", c.id), rect); + if resp.clicked() || resp.drag_started() { + pu.selected = Some(c.id); + } + if resp.drag_started() { + pu.drag = Some(Drag { cell: c.id, origin: c.rect, resize: false, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == c.id && !d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (x, y) = (d.origin.x + f64::from(d.total.x / sheet.scale), d.origin.y + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run(app, &ctx, "print.cell.move", json!({"id": c.id, "x": x, "y": y, "snap": true, "threshold": threshold})); + } + if resp.drag_stopped() { + pu.drag = None; + } + let id = c.id; + resp.context_menu(|ui| { + for (how, label) in + [("toFront", "Bring to Front"), ("forward", "Bring Forward"), ("backward", "Send Backward"), ("toBack", "Send to Back")] + { + if ui.button(crate::i18n::tr(label)).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("Delete Cell")).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + ui.close(); + } + }); + } + // the selected cell's outline and resize handle go over everything + let Some(sel) = pu.selected.and_then(|s| cells.iter().find(|c| c.id == s)) else { return }; + let rect = sheet.rect(&sel.rect); + painter.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + let handle = Rect::from_center_size(rect.right_bottom(), Vec2::splat(HANDLE)); + painter.rect_filled(handle, 2.0, t.accent); + let resp = ui.interact(handle, egui::Id::new(("print-resize", sel.id)), Sense::drag()); + register(&ctx, format!("print:resize:{}", sel.id), handle); + if resp.drag_started() { + pu.drag = Some(Drag { cell: sel.id, origin: sel.rect, resize: true, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == sel.id && d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (w, h) = (d.origin.w + f64::from(d.total.x / sheet.scale), d.origin.h + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run( + app, + &ctx, + "print.cell.resize", + json!({"id": sel.id, "width": w.max(layout::MIN_CELL_IN), "height": h.max(layout::MIN_CELL_IN), "snap": true, "threshold": threshold}), + ); + } + if resp.drag_stopped() { + pu.drag = None; + } +} + +/// The selected cell's numbers, its place in the stack and its photo. +fn inspector(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings) { + let ctx = ui.ctx().clone(); + let Some(cell) = pu.selected.and_then(|id| st.layout.cell(id)) else { + ui.label(egui::RichText::new(crate::i18n::tr("Click a cell to move, resize or arrange it")).color(t.text_dim)); + return; + }; + let (id, r, unit) = (cell.id, cell.rect, st.guides.units); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if let Some(v) = length(ui, "printCellX", r.x, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": v, "y": r.y})); + } + if let Some(v) = length(ui, "printCellY", r.y, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": r.x, "y": v})); + } + if let Some(v) = length(ui, "printCellW", r.w, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": v, "height": r.h})); + } + if let Some(v) = length(ui, "printCellH", r.h, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": r.w, "height": v})); + } + for (i, (how, label)) in [("toBack", "Back"), ("backward", "Backward"), ("forward", "Forward"), ("toFront", "Front")].into_iter().enumerate() + { + if text_button(ui, &format!("printStack-{i}"), label, false).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + } + } + if st.layout.style == Style::CustomPackage { + if text_button(ui, "printCellPhoto", "Use Active Photo", false).clicked() { + let active = app.session.active().map(|p| p.0); + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": active})); + } + if cell.photo.is_some() && text_button(ui, "printCellPhotoClear", "Clear Photo", false).clicked() { + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": null})); + } + } + if text_button(ui, "printCellDelete", "Delete", false).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + pu.selected = None; + } + }); +} + +/// One cell on screen: the photo as it will print (fit or fill, turned, border, stroke). +#[allow(clippy::too_many_arguments)] +fn paint_cell( + app: &mut LightcraftApp, + painter: &egui::Painter, + t: &Tokens, + st: &PrintSettings, + cell: Rect, + photo: Option, + sheet: &Sheet, + inches: &layout::Rect, +) { + let im = &st.image; + if st.guides.show_cells { + painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside); + } + let border = if st.layout.style.is_package() && im.border_enabled { + (im.border_width as f32 * sheet.scale).min(cell.width().min(cell.height()).max(2.0) / 2.0 - 1.0).max(0.0) + } else { + 0.0 + }; + let avail = cell.shrink(border); + if avail.width() < 1.0 || avail.height() < 1.0 { + return; + } + // the photo's shape: its cropped size, or 3:2 for a placeholder + let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| { + let (w, h) = lightcraft_engine::export::output_size(p, &ExportOptions::default()); + (w as f64, h as f64) + }); + let scale = 8.0; // sub-point resolution for the rounding in `place` + let aw = (f64::from(avail.width()) * scale).round() as usize; + let ah = (f64::from(avail.height()) * scale).round() as usize; + let Some(p) = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit) else { return }; + let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32); + let visible = shown.intersect(avail); + if border > 0.0 { + let frame = visible.expand(border); + let c = im.border_color; + painter.rect_filled(frame.intersect(cell), 0.0, Color32::from_rgb(c[0], c[1], c[2])); + } + let clip = painter.with_clip_rect(avail.intersect(painter.clip_rect())); + let tex = photo.and_then(|id| { + // Use the library's thumbnail when there is one. A thumbnail is kept at one size, so asking + // for another size here every frame made the preview and the grid behind the dialog + // re-render each other's thumbnail for as long as the dialog stayed open. + if app.renderer.thumb(id).is_none() { + let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; + crate::panels::grid::request_thumb(app, id, px, 10); + } + app.renderer.thumb(id).map(|tex| tex.tex.id()) + }); + match tex { + Some(id) => { + let mut mesh = egui::Mesh::with_texture(id); + // an unturned quad maps the texture straight; a turned one (a quarter clockwise) takes + // the left edge to the top + let uv: [Pos2; 4] = if p.rotated { + [pos2(0.0, 1.0), pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0)] + } else { + [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)] + }; + let corners = [shown.left_top(), shown.right_top(), shown.right_bottom(), shown.left_bottom()]; + for (pos, uv) in corners.into_iter().zip(uv) { + mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + clip.add(egui::Shape::mesh(mesh)); + } + None => { + clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) }); + } + } + if im.stroke_enabled && im.stroke_width_pt > 0.0 { + let w = (im.stroke_width_pt as f32 / 72.0 * sheet.scale).max(1.0).min(visible.width().min(visible.height()) / 2.0); + let c = im.stroke_color; + painter.rect_stroke(visible, 0.0, Stroke::new(w, Color32::from_rgb(c[0], c[1], c[2])), StrokeKind::Inside); + } + if st.guides.show_dimensions { + let text = format!("{} × {}", fmt_len(inches.w, st.guides.units), fmt_len(inches.h, st.guides.units)); + let galley = painter.layout_no_wrap(text, t.font(10.0), Color32::WHITE); + let at = pos2(cell.center().x - galley.size().x / 2.0, cell.bottom() - galley.size().y - 3.0); + painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160)); + painter.galley(at, galley, Color32::WHITE); + } +} + +fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) { + let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + painter.extend(egui::Shape::dashed_line(&pts, stroke, 5.0, 4.0)); +} + +/// Rulers along the top and left of the preview, in `unit`, zero at the page's corner. +fn rulers(painter: &egui::Painter, t: &Tokens, sheet: &Sheet, area: Rect, pw: f64, ph: f64, unit: Unit) { + let (major, minor) = match unit { + Unit::Inches => (1.0, 0.25), + Unit::Centimeters => (1.0, 0.5), + Unit::Millimeters => (10.0, 5.0), + Unit::Points => (72.0, 18.0), + }; + let tick = Stroke::new(1.0, t.text_dim); + let ink = t.text_dim; + let top = Rect::from_min_max(area.min, pos2(area.max.x, area.min.y + 20.0)); + let left = Rect::from_min_max(area.min, pos2(area.min.x + 20.0, area.max.y)); + painter.rect_filled(top, 0.0, t.chrome); + painter.rect_filled(left, 0.0, t.chrome); + let ticks = |len_in: f64| (0..).map(move |k| f64::from(k) * minor).take_while(move |v| *v <= unit.from_inches(len_in) + 1e-9).take(2000); + for v in ticks(pw) { + let x = sheet.page.left() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(x, top.bottom()), pos2(x, top.bottom() - if is_major { 10.0 } else { 5.0 })], tick); + if is_major { + painter.text(pos2(x + 2.0, top.top() + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } + for v in ticks(ph) { + let y = sheet.page.top() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(left.right(), y), pos2(left.right() - if is_major { 10.0 } else { 5.0 }, y)], tick); + if is_major { + painter.text(pos2(left.left() + 1.0, y + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lengths_read_in_the_ruler_unit() { + assert_eq!(fmt_len(1.0, Unit::Inches), "1.00 in"); + assert_eq!(fmt_len(1.0, Unit::Millimeters), "25.4 mm"); + assert_eq!(fmt_len(1.0, Unit::Points), "72 pt"); + assert_eq!(trim(4.0), "4"); + assert_eq!(trim(2.5), "2.5"); + assert_eq!(paper_label("a4"), "A4"); + assert_eq!(paper_label("4x6"), "4 × 6 in"); + } +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d844772e..b9d1874a1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -446,7 +446,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // a slideshow: Space pauses / resumes if id == "view.zoomToggle" && app.ui.slideshow.is_some() { if let Ok(v) = app.run("slideshow.togglePause", json!({})) { - app.toast(ctx, if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" }); + app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" })); } continue; } @@ -573,7 +573,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // B: the brush while editing; in the grids, add to the target album (Quick Collection) if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { if let Ok(r) = app.run("album.toggleTarget", json!({})) { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); app.toast( diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 09c5bf4a1..ccdded8bb 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -907,6 +907,9 @@ pub struct UiState { pub status: String, #[serde(skip)] pub dialog: Option, + /// The Print dialog's page, selection and drag. + #[serde(skip)] + pub print: crate::print_view::PrintUi, } /// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything @@ -942,6 +945,9 @@ pub enum Dialog { details: Vec, first: Option, }, + /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show + /// and the selected cell are [`UiState::print`]. + Print, ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, @@ -1362,6 +1368,7 @@ impl Default for UiState { rechecked_library: None, status: String::new(), dialog: None, + print: Default::default(), } } } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 493998fd3..126b3ef58 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -9,7 +9,8 @@ use crate::headless::Headless; use crate::panels::activity::{WIDTH, count_text}; use crate::{LightcraftApp, Services}; -const T: Duration = Duration::from_secs(20); +// generous: merges and renders run on worker threads that a busy machine (a full workspace test run) slows a lot +const T: Duration = Duration::from_secs(60); fn demo() -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); diff --git a/crates/ui-egui/src/tests_button.rs b/crates/ui-egui/src/tests_button.rs new file mode 100644 index 000000000..e06948fff --- /dev/null +++ b/crates/ui-egui/src/tests_button.rs @@ -0,0 +1,159 @@ +//! The button component: its sizes, its looks in every theme and state, what screen readers hear, +//! and that a disabled one can't be clicked. + +use egui::{Color32, Pos2, Rect, pos2, vec2}; + +use crate::button::{Button, Kind, Size, State, contrast, look}; +use crate::icons::Icon; +use crate::theme::{ThemeKind, Tokens}; + +/// Run frames of `draw` (with pointer `events` in the second), returning the AccessKit nodes' +/// (role, name) and what `draw` reported. +fn frames(events: Vec, mut draw: impl FnMut(&mut egui::Ui) -> T) -> (Vec<(String, String)>, T) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut found = Vec::new(); + let mut last = T::default(); + for frame in 0..3 { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events: if frame == 1 { events.clone() } else { Vec::new() }, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| last = draw(ui)); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + found = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect(); + } + } + (found, last) +} + +#[test] +fn the_three_sizes_and_an_icon_alone() { + let (_, sizes) = frames(Vec::new(), |ui| { + [Size::Small, Size::Medium, Size::Large] + .map(|s| (Button::new("Import Photos…").size(s).desired_size(ui), Button::icon_only(Icon::Trash, "Delete").size(s).desired_size(ui))) + .to_vec() + }); + let heights: Vec = sizes.iter().map(|(text, _)| text.y).collect(); + assert_eq!(heights, vec![24.0, 30.0, 36.0]); + for (text, icon) in &sizes { + assert_eq!(icon.x, icon.y, "an icon alone is square"); + assert!(text.x > icon.x, "text makes it wider"); + } + // an icon beside the text makes it wider still, and a minimum width holds + let (_, (plain, with_icon, wide)) = frames(Vec::new(), |ui| { + ( + Button::new("Remove").desired_size(ui).x, + Button::new("Remove").icon(Icon::Trash).desired_size(ui).x, + Button::new("OK").min_width(120.0).desired_size(ui).x, + ) + }); + assert!(with_icon > plain); + assert_eq!(wide, 120.0); +} + +#[test] +fn a_screen_reader_hears_every_button_by_its_name() { + let (found, _) = frames(Vec::new(), |ui| { + Button::new("Import Photos…").primary().show(ui); + Button::icon_only(Icon::Trash, "Delete mask").ghost().show(ui); + Button::new("Remove").icon(Icon::Trash).danger().enabled(false).show(ui); + }); + let has = |label: &str| found.iter().any(|(r, l)| r == "Button" && l == label); + assert!(has("Import Photos…"), "{found:?}"); + assert!(has("Delete mask"), "an icon alone is named: {found:?}"); + assert!(has("Remove"), "a disabled one is still announced: {found:?}"); +} + +#[test] +fn text_on_a_fill_reads_at_aa_in_every_theme_and_state() { + for kind in ThemeKind::ALL { + let t = Tokens::for_kind(kind); + for k in [Kind::Primary, Kind::Danger, Kind::Secondary] { + for state in [State::Idle, State::Hovered, State::Pressed] { + let lk = look(&t, k, state); + let fill = lk.fill.unwrap_or(t.canvas); + let c = contrast(lk.content, fill); + assert!(c >= 4.5, "{kind:?} {k:?} {state:?}: {c:.2}:1"); + } + } + // a ghost's text on the surfaces it sits on + let ghost = look(&t, Kind::Ghost, State::Idle); + assert!(ghost.fill.is_none()); + assert!(contrast(ghost.content, t.chrome) >= 4.5, "{kind:?} ghost on chrome"); + } +} + +#[test] +fn hover_and_press_are_darker_and_disabled_is_faded() { + let t = Tokens::for_kind(ThemeKind::Charcoal); + let luma = |c: Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b()); + let fill = |s| look(&t, Kind::Primary, s).fill.map(luma).unwrap_or(0); + assert!(fill(State::Hovered) < fill(State::Idle)); + assert!(fill(State::Pressed) < fill(State::Hovered)); + let disabled = look(&t, Kind::Primary, State::Disabled); + assert!(disabled.content.a() < 255, "faded"); +} + +#[test] +fn a_disabled_button_cannot_be_clicked() { + let at = pos2(40.0, 15.0); + let click = vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ]; + let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)); + let mut disabled_clicked = false; + let _ = frames(click.clone(), |ui| { + if Button::new("Delete").danger().enabled(false).show_at(ui, place).clicked() { + disabled_clicked = true; + } + }); + assert!(!disabled_clicked, "on any frame"); + let mut seen = false; + let _ = frames(click, |ui| { + if Button::new("Delete").danger().show_at(ui, place).clicked() { + seen = true; + } + }); + assert!(seen, "an enabled one is"); +} + +#[test] +fn buttons_in_one_place_with_the_same_text_are_told_apart() { + // two "Delete mask" buttons side by side, placed by the caller: two widgets, not one + let (found, _) = frames(Vec::new(), |ui| { + let a = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(30.0, 30.0))); + let b = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(40.0, 0.0), vec2(30.0, 30.0))); + assert_ne!(a.id, b.id); + }); + assert_eq!(found.iter().filter(|(r, l)| r == "Button" && l == "Delete mask").count(), 2, "{found:?}"); +} + +#[test] +fn a_button_in_a_disabled_part_of_the_window_says_it_is_disabled() { + // (egui refuses the click there anyway: what the button adds is looking and reading disabled) + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut disabled = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.add_enabled_ui(false, |ui| { + let resp = Button::new("Delete").show_at(ui, Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0))); + // drawn as disabled: it asks for hover only, as a button turned off does + assert!(!resp.sense.senses_click(), "drawn as a button that can be clicked"); + }); + }); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + disabled = update.nodes.iter().find(|(_, n)| n.label() == Some("Delete")).map(|(_, n)| n.is_disabled()); + } + } + assert_eq!(disabled, Some(true), "a screen reader hears it disabled"); +} diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 18345812c..52d412829 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -95,8 +95,9 @@ fn export_dialog_is_unchanged_in_a_tall_window() { let dialog = rect(&h, "dialog:window"); // (846 tall as before #781: the whole dialog, nothing scrolled away, plus the Export to row // since #956; 615 wide since the format row gained JPEG XL, #780. On macOS the dialog has one - // more row, "Add to Apple Photos" (#236), so it is 30 taller there) - let height = if cfg!(target_os = "macos") { 908.0 } else { 878.0 }; + // more row, "Add to Apple Photos" (#236), so it is 30 taller there. 8 more since its footer's + // buttons are the button component's medium size, 30 points, as the welcome's and every dialog's) + let height = if cfg!(target_os = "macos") { 916.0 } else { 886.0 }; assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}"); assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}"); } diff --git a/crates/ui-egui/src/tests_dialog_primary.rs b/crates/ui-egui/src/tests_dialog_primary.rs new file mode 100644 index 000000000..29078d009 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_primary.rs @@ -0,0 +1,74 @@ +//! A dialog's confirm button reads as its primary action: on the right, Cancel to its left, and +//! filled with the accent when it makes or brings in something (Import, Merge, Save…). A +//! destructive confirm (Delete, Remove) stays plain: never the eye-catching default. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::dialogs::{ConfirmStyle, confirm_style}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")) +} + +fn png(dir: &std::path::Path, name: &str) -> String { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() +} + +#[test] +fn the_import_review_puts_its_confirm_on_the_right_as_the_primary_action() { + // Given the import review of two photos + let dir = std::env::temp_dir().join(format!("lc-ui-dialog-primary-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let paths = [png(&dir, "a.png"), png(&dir, "b.png")]; + let app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("file.addPhotos", json!({"paths": paths})).unwrap(); + h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:dialogOk")); + h.step(); + // Then "Import 2 Photos" is right of Cancel, at the dialog's right edge + let (ok, cancel, window) = (rect(&h, "button:dialogOk"), rect(&h, "button:dialogCancel"), rect(&h, "dialog:window")); + assert!(ok.left() > cancel.right(), "confirm {ok:?} right of cancel {cancel:?}"); + assert!(window.right() - ok.right() < 40.0, "confirm {ok:?} at the right edge of {window:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn constructive_confirms_are_primary_and_destructive_ones_plain() { + use crate::state::Dialog; + assert_eq!(confirm_style(&Dialog::AutoStack { gap: 60.0 }), ConfirmStyle::Primary); + assert_eq!(confirm_style(&Dialog::ConfirmDelete { count: 3 }), ConfirmStyle::Plain); + // a dialog that only shows something has nothing to confirm: its Close is plain + assert_eq!(confirm_style(&Dialog::About), ConfirmStyle::Plain); +} + +#[test] +fn a_dialog_wider_than_its_content_still_ends_its_footer_at_its_edge() { + // Auto-Stack's title makes it wider than its slider and note: the buttons go to the edge, and + // the window doesn't grow for them + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.autoStack", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + let (ok, window) = (rect(&h, "button:dialogOk"), rect(&h, "dialog:window")); + assert!(window.right() - ok.right() < 24.0, "confirm {ok:?} at the right edge of {window:?}"); + let width = window.width(); + for _ in 0..4 { + h.step(); + } + assert_eq!(rect(&h, "dialog:window").width(), width, "the footer doesn't widen the window"); +} diff --git a/crates/ui-egui/src/tests_edit_layout.rs b/crates/ui-egui/src/tests_edit_layout.rs new file mode 100644 index 000000000..7c6d79ff0 --- /dev/null +++ b/crates/ui-egui/src/tests_edit_layout.rs @@ -0,0 +1,68 @@ +//! The Edit panel at its narrowest with a long profile applied: the profile row fits (#1759). + +use egui::{Rect, vec2}; +use serde_json::json; + +use crate::{LightcraftApp, Services, headless::HeadlessView}; + +const LONG_ID: &str = "lut:kodak-portra-400-plus-one-stop-overexposed"; +const LONG_NAME: &str = "Kodak Portra 400 Plus One Stop Overexposed"; + +fn text_shapes(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)), + _ => {} + } +} + +fn panel(width: f32) -> (Vec, Vec<(String, Rect)>) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: LONG_ID.into(), + name: LONG_NAME.into(), + group: "Film Stocks".into(), + file: String::new(), + }); + app.run("develop.profile", json!({"id": LONG_ID})).unwrap(); + let photo = app.session.active().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut texts = Vec::new(); + let mut widgets = Vec::new(); + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(width, 4000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + crate::panels::edit::show(&mut app, ui, photo); + }); + }); + out.textures_delta.clear(); + texts.clear(); + for shape in &out.shapes { + text_shapes(&shape.shape, &mut texts); + } + widgets = crate::widgets::take_registry(&ctx); + } + (texts, widgets) +} + +#[test] +fn a_long_profile_name_leaves_the_edit_panel_its_width() { + let width = crate::state::RIGHT_WIDTH.min; + let (texts, widgets) = panel(width); + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not drawn")).1; + let dropdown = rect("dropdown:profile"); + let browse = rect("icon:profileBrowser"); + assert!(dropdown.right() <= browse.left(), "the profile menu {dropdown:?} runs into Browse Profiles {browse:?}"); + let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME).unwrap(); + assert!(name.galley.elided, "a name wider than the row is cut short"); + assert!(name.visual_bounding_rect().right() <= dropdown.right(), "the name leaves its menu"); + // every slider drawn, from the profile's Amount down + rect("sliderValue:profile.amount"); + rect("sliderValue:light.exposure"); + for (id, r) in widgets.iter().filter(|(id, _)| id.starts_with("slider")) { + assert!(r.right() <= width, "{id} {r:?} is cut off by the {width} pt panel"); + } +} diff --git a/crates/ui-egui/src/tests_empty_library.rs b/crates/ui-egui/src/tests_empty_library.rs new file mode 100644 index 000000000..2207875b9 --- /dev/null +++ b/crates/ui-egui/src/tests_empty_library.rs @@ -0,0 +1,188 @@ +//! LR-BEHAV-EMPTY-STATES: a library with no photos leads to Import. It opens on the Photo Grid +//! with the sidebar, the grid and the Detail view offer Import Photos… and Import Lightroom +//! Catalog… as buttons, and the top bar has an Import button for every library. + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::empty_library; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn app(session: lightcraft_engine::Session) -> LightcraftApp { + LightcraftApp::new(session, Services::default()) +} + +/// A picker that records that it was asked and gives no files (the person cancelled). +fn counting_picker(h: &mut Headless) -> Rc> { + let asked = Rc::new(Cell::new(0)); + let seen = asked.clone(); + h.app.services.pick_files = Some(Box::new(move || { + seen.set(seen.get() + 1); + Vec::new() + })); + asked +} + +#[test] +fn an_empty_library_opens_on_the_grid_with_the_sidebar() { + // Given a library with no photos, left in the Detail view with the sidebar hidden + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + a.ui.left_panel = false; + // When it opens at launch + empty_library::open_on_empty_library(&mut a); + // Then it shows the Photo Grid and the sidebar, where Import is offered + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + assert!(a.ui.left_panel); +} + +#[test] +fn an_empty_library_left_on_the_square_grid_stays_there() { + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::SquareGrid; + empty_library::open_on_empty_library(&mut a); + assert_eq!(a.ui.view, ViewMode::SquareGrid); + assert!(a.ui.left_panel); +} + +#[test] +fn a_library_with_photos_opens_as_it_was_left() { + let mut a = app(lightcraft_engine::Session::with_demo()); + a.ui.view = ViewMode::Detail; + a.ui.left_panel = false; + empty_library::open_on_empty_library(&mut a); + assert_eq!(a.ui.view, ViewMode::Detail); + assert!(!a.ui.left_panel); +} + +#[test] +fn deleted_photos_leave_the_library_empty() { + let mut s = lightcraft_engine::Session::with_demo(); + assert!(!empty_library::is_empty(&s)); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + s.selection.ids = ids.clone(); + s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + assert!(empty_library::is_empty(&s), "photos in Recently Deleted are not in the library"); +} + +#[test] +fn the_empty_grid_offers_import_as_buttons() { + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::PhotoGrid; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:emptyImportPhotos")); + #[cfg(not(target_arch = "wasm32"))] + assert!(has(&h, "button:emptyImportLightroom")); + // a click asks for the photos, as File ▸ Import Photos… does + let asked = counting_picker(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:emptyImportPhotos"}), T)["ok"], true); + h.step(); + assert_eq!(asked.get(), 1); +} + +#[test] +fn the_empty_detail_view_offers_import_instead_of_the_grid() { + // the dead end the critique found: Detail pointed at a grid with nothing in it + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:emptyImportPhotos")); +} + +#[test] +fn detail_with_photos_but_none_selected_keeps_its_message() { + let mut a = app(lightcraft_engine::Session::with_demo()); + a.ui.view = ViewMode::Detail; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.app.session.selection.ids.clear(); + h.app.session.selection.active = None; + h.step(); + h.step(); + assert!(h.app.session.active().is_none()); + assert!(!has(&h, "button:emptyImportPhotos"), "a library with photos needs no import prompt"); +} + +#[test] +fn the_top_bar_imports_in_every_library() { + let mut h = Headless::new(app(lightcraft_engine::Session::with_demo()), [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "icon:import")); + let asked = counting_picker(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:import"}), T)["ok"], true); + h.step(); + assert_eq!(asked.get(), 1); +} + +#[test] +fn the_hint_shows_the_shortcut_as_bound() { + let mut keymap = crate::state::AppSettings::default().keymap; + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⇧I"); + assert_eq!(empty_library::hint(&keymap, false), "or drop photos and folders into this window · Ctrl+Shift+I"); + // a remapped shortcut is the one shown + crate::shortcuts::assign(&mut keymap, "file.addPhotos", Some("Cmd+Alt+I")).unwrap(); + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⌥I"); + // and none at all leaves the key out + crate::shortcuts::assign(&mut keymap, "file.addPhotos", None).unwrap(); + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window"); +} + +#[test] +fn a_folder_browsed_under_local_is_not_the_empty_library() { + // its photos aren't in the library, but they are what is on screen: no import prompt + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + a.session.source = lightcraft_engine::LibrarySource::Folder; + assert!(empty_library::is_empty(&a.session)); + assert!(!empty_library::shown(&a.session)); + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(!has(&h, "button:emptyImportPhotos")); +} + +#[test] +fn recently_deleted_is_not_the_empty_library() { + // the deleted photos are on screen there: no import prompt beside them + let mut s = lightcraft_engine::Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + s.selection.ids = ids.clone(); + s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + s.source = lightcraft_engine::LibrarySource::RecentlyDeleted; + assert!(empty_library::is_empty(&s)); + assert!(!empty_library::shown(&s)); +} + +#[test] +fn the_import_button_leaves_the_filter_and_appearance_icons_apart() { + // the search field is centred: a seventh icon on the right must not push it into them + // with the menus in the window (Windows, Linux, web) and in the native menu bar (macOS) + for (width, native_menu) in [900.0, 906.0, 1024.0, 1200.0, 1600.0].into_iter().flat_map(|w| [(w, false), (w, true)]) { + let mut a = app(lightcraft_engine::Session::with_demo()); + // a native menu bar comes with the title bar drawn in the window (macOS): a wider left margin + a.native_menu = native_menu; + a.integrated_titlebar = native_menu; + let mut h = Headless::new(a, [width, 800.0], 1.0); + h.step(); + h.step(); + let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} at {width}")); + let (filter, appearance, import) = (rect("icon:filter"), rect("icon:appearance"), rect("icon:import")); + assert!(!filter.intersects(appearance), "{width} (native menu {native_menu}): filter {filter:?} meets appearance {appearance:?}"); + assert!(!filter.intersects(import), "{width} (native menu {native_menu}): filter {filter:?} meets import {import:?}"); + } +} diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 657e5f337..bdeaef0dc 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -428,3 +428,69 @@ fn dropping_photos_counts_those_that_get_the_keyword() { let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); assert!(toast.contains("1 photo") && !toast.contains("2 photos"), "{toast}"); } + +/// Scrolled down a long list, ticking and unticking keywords leaves the tree where it is: the +/// Keywording box above grows and shrinks out of sight instead of pushing the rows down. +#[test] +fn ticking_a_keyword_keeps_the_list_in_place() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + assert!(rect(&h, "field:keyword").top() < 0.0, "the box is scrolled out of sight"); + let mut box_moved = false; + let picked: Vec = (30..36).map(|i| format!("zz{i}")).collect(); + for (n, k) in picked.iter().chain(picked.iter().rev()).enumerate() { + let (before, box_top) = (rect(&h, &format!("keywordRow:{k}")), rect(&h, "field:keyword").top()); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + assert_eq!(rect(&h, &format!("keywordRow:{k}")), before, "{k} stayed under the pointer"); + // (the click found the row where it is drawn, not where a discarded pass had it) + assert_eq!(keywords_of(&h, ids[0]).contains(k), n < picked.len(), "{k} was the one ticked: {:?}", keywords_of(&h, ids[0])); + box_moved |= rect(&h, "field:keyword").top() != box_top; + } + assert!(keywords_of(&h, ids[0]).is_empty(), "ticked and unticked again"); + assert!(box_moved, "the box changed height (the list was held, not left alone)"); +} + +/// At the top of the panel the box is in sight: a ticked keyword's chip pushes the list down, +/// as before. +#[test] +fn at_the_top_the_box_grows_in_sight() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + let (title, box_top) = (rect(&h, "keywordList:topLevel"), rect(&h, "field:keyword")); + for k in ["aaa1", "aaa2", "aaa3", "aaa4", "aaa5", "aaa6"] { + run(&mut h, "keyword.create", json!({"name": k})); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + } + assert_eq!(rect(&h, "field:keyword"), box_top, "the panel stays at its top"); + assert!(rect(&h, "keywordList:topLevel").top() > title.top(), "the chips pushed the list down"); +} + +/// Ticks clicked one right after another land on the rows clicked: a click looks a row up where +/// it was last drawn, not where a pass drawn again (the list held in place) first had it. +#[test] +fn ticks_in_quick_succession_land_on_their_rows() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + let picked: Vec = (30..36).map(|i| format!("zz{i}")).collect(); + for k in &picked { + // (no settling in between, as a script or a quick hand clicks) + let r = h.request("ui.clickWidget", json!({"id": format!("keywordCheck:{k}")}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let mut have = keywords_of(&h, ids[0]); + have.sort(); + assert_eq!(have, picked); +} diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index e47ba879a..95b02967c 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -191,7 +191,7 @@ fn the_active_photo_alone_is_the_selection() { assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(ids[0])); assert!(h.app.session.selection.ids.is_empty()); assert!(has(&h, "keywordChip:Weddings") && !has(&h, "keywordChipPartial:Weddings"), "the active photo's keywords"); - let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.targets(&json!({}))); + let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.selected()); assert_eq!(ticks.tick("Weddings"), crate::panels::keyword_list::Tick::All); assert!(has(&h, "keywordCheck:beach")); } @@ -792,3 +792,24 @@ fn offers_never_cover_the_box() { } } } + +/// Rename Keyword's "used by n photos" and Create Smart Album's live count scan the library once +/// per change to it, not every frame the dialog is open. +#[test] +fn open_dialogs_count_once_per_library_change() { + let (mut h, ids) = two_selected(); + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "beach".into(), to: "beach".into() }); + let before = h.app.caches.dialog_count_scans; + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 1, "unchanged frames reuse the count"); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[1]], "keywords": ["sea"]}})); + h.step(); + assert_eq!(h.app.caches.dialog_count_scans, before + 2, "a library change counts again"); + h.app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: "Beach".into(), parent: None }); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 3, "another dialog counts its own photos, once"); +} diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 5b979ccdc..8e360f862 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2392,3 +2392,20 @@ fn histogram_corners_toggle_clipping_warnings() { h.step(); assert!(!h.app.ui.show_clipping); } + +#[test] +fn the_histogram_clipping_buttons_stay_clear_of_the_hovered_scroll_bar() { + // every section open: the Edit panel scrolls, so its scroll bar runs past the histogram + let h = demo( + [1400.0, 700.0], + json!({"view": "detail", "right": "edit", "histogram": true, "openSections": ["light", "color", "effects", "detail", "optics"]}), + ); + let hist = widget(&h, "histogram"); + let (low, high) = (widget(&h, "histogram:shadows"), widget(&h, "histogram:highlights")); + // a hovered floating scroll bar grows to its full width at the panel's right edge, over the content + let scroll = h.view.ctx.global_style().spacing.scroll; + let bar_left = hist.right() - scroll.bar_outer_margin - scroll.bar_width; + assert!(high.right() <= bar_left, "the highlight clipping button {high:?} is under the scroll bar (from x = {bar_left})"); + // and the shadow one keeps the same inset from its edge + assert!(((low.left() - hist.left()) - (hist.right() - high.right())).abs() < 0.5, "{low:?} {high:?} in {hist:?}"); +} diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs new file mode 100644 index 000000000..29c214e47 --- /dev/null +++ b/crates/ui-egui/src/tests_print.rs @@ -0,0 +1,380 @@ +//! The Print dialog, driven through the control channel: styles, cells dragged and resized with +//! real pointer input, Auto Layout, pages, templates and the PDF. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(30); + +fn demo(services: Services) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn drag(h: &mut Headless, id: &str, dx: f32, dy: f32) { + let r = h.request("ui.dragWidget", json!({"id": id, "dx": dx, "dy": dy, "steps": 8}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn cmd(h: &mut Headless, id: &str, params: Value) -> Value { + let r = h.request("engine.execute", json!({"command": id, "params": params}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); + r["result"].clone() +} + +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + +fn open_print(h: &mut Headless) { + cmd(h, "dialog.print", json!({})); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Print))); + assert!(has(h, "print:page"), "the page is on screen"); +} + +/// Screen points per inch of the page shown. +fn scale(h: &Headless) -> f32 { + rect(h, "print:page").width() / h.app.session.print.layout.page_size().unwrap().0 as f32 +} + +fn cell_rect(h: &Headless, id: u32) -> lightcraft_engine::print::layout::Rect { + h.app.session.print.layout.cell(id).unwrap().rect +} + +#[test] +fn the_print_dialog_opens_from_the_menu_command_and_closes() { + let mut h = demo(Services::default()); + open_print(&mut h); + assert!(has(&h, "button:printStyle-0") && has(&h, "check:printZoomFill") && has(&h, "button:dialogOk")); + assert!(rect(&h, "print:pageLabel").width() > 0.0); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); +} + +#[test] +fn style_buttons_and_checkboxes_run_print_set() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + assert_eq!(h.app.session.print.layout.style, lightcraft_engine::print::layout::Style::ContactSheet); + assert!(has(&h, "drag:printColumns") && has(&h, "print:gridcell:0")); + click(&mut h, "check:printZoomFill"); + click(&mut h, "check:printStroke"); + assert!(h.app.session.print.image.zoom_to_fill && h.app.session.print.image.stroke_enabled); + click(&mut h, "button:printOrientation-1"); + assert_eq!(h.app.session.print.layout.page.orientation, lightcraft_engine::print::layout::Orientation::Landscape); + let p = rect(&h, "print:page"); + assert!(p.width() > p.height(), "the page is drawn landscape"); + click(&mut h, "button:printUnits-2"); + assert_eq!(h.app.session.print.guides.units, lightcraft_engine::print::layout::Unit::Millimeters); + click(&mut h, "check:printShowGrid"); + assert!(h.app.session.print.guides.show_page_grid); +} + +#[test] +fn cells_are_added_dragged_resized_and_arranged() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + // snapping off, so the pointer maths is exact + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + click(&mut h, "button:printAddCell-4"); + click(&mut h, "button:printAddCell-1"); + let cells: Vec = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(cells.len(), 2); + assert_eq!(h.app.session.print.layout.cells[0].rect.w, 4.0); + assert!(has(&h, &format!("print:cell:{}", cells[0]))); + + // drag the first cell with the pointer: the move is in inches, through print.cell.move + let before = cell_rect(&h, cells[0]); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", cells[0]), 0.0, 100.0); + let after = cell_rect(&h, cells[0]); + assert!((after.y - before.y - f64::from(100.0 / s)).abs() < 0.05, "moved {} in, expected {}", after.y - before.y, 100.0 / s); + assert!((after.x - before.x).abs() < 0.05); + assert_eq!(h.app.ui.print.selected, Some(cells[0])); + + // resize with its corner handle + let r0 = cell_rect(&h, cells[0]); + drag(&mut h, &format!("print:resize:{}", cells[0]), 30.0, 20.0); + let r1 = cell_rect(&h, cells[0]); + assert!((r1.w - r0.w - f64::from(30.0 / s)).abs() < 0.05 && (r1.h - r0.h - f64::from(20.0 / s)).abs() < 0.05, "{r0:?} → {r1:?}"); + assert_eq!((r1.x, r1.y), (r0.x, r0.y), "the top-left corner stays"); + + // arrange: Front moves it last + click(&mut h, "button:printStack-3"); + assert_eq!(h.app.session.print.layout.cells.last().unwrap().id, cells[0]); + click(&mut h, "button:printStack-0"); + assert_eq!(h.app.session.print.layout.cells.first().unwrap().id, cells[0]); + + // the inspector's delete + click(&mut h, "button:printCellDelete"); + assert_eq!(h.app.session.print.layout.cells.len(), 1); + assert_eq!(h.app.ui.print.selected, None); +} + +#[test] +fn dragging_a_cell_snaps_to_its_neighbour_when_snap_is_on() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + cmd(&mut h, "print.set", json!({"layout": {"spacing": 0.0}})); + click(&mut h, "button:printAddCell-0"); + click(&mut h, "button:printAddCell-0"); + let ids: Vec = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(h.app.session.print.guides.snap, lightcraft_engine::print::layout::Snap::Cells); + let a = cell_rect(&h, ids[0]); + // park the second cell away from the first, then drag it back to 0.06 in from the first's right edge + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - a.right()).abs() < 1e-6, "snapped to the neighbour's edge: {} vs {}", r.x, a.right()); + // with snapping off the same drag lands where the pointer put it + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - (a.right() + 0.06)).abs() < 0.05 && r.x > a.right() + 0.01, "unsnapped: {}", r.x - a.right()); +} + +#[test] +fn auto_layout_new_page_and_clear_buttons() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-2"); + for _ in 0..3 { + click(&mut h, "button:printAddCell-4"); + } + for _ in 0..3 { + click(&mut h, "button:printAddCell-5"); + } + let before_pages = h.app.session.print.layout.pages; + // scramble everything onto one spot, then pack + cmd(&mut h, "print.layout.autoLayout", json!({})); + click(&mut h, "button:printAutoLayout"); + assert!(!h.app.session.print.layout.has_overlaps()); + assert!(h.app.session.print.layout.pages >= before_pages.min(2)); + let pages = h.app.session.print.layout.pages; + click(&mut h, "button:printNewPage"); + assert_eq!(h.app.session.print.layout.pages, pages + 1); + assert_eq!(h.app.ui.print.page as u32, pages, "the new page is shown"); + // the next-page button stays on the last page + click(&mut h, "button:printPageNext"); + assert_eq!(h.app.ui.print.page as u32, pages); + click(&mut h, "button:printPagePrev"); + assert_eq!(h.app.ui.print.page as u32, pages - 1); + click(&mut h, "button:printClear"); + assert_eq!((h.app.session.print.layout.cells.len(), h.app.session.print.layout.pages), (0, 1)); + assert_eq!(h.app.ui.print.page, 0); +} + +#[test] +fn templates_save_apply_and_delete_from_the_dialog() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + cmd(&mut h, "print.set", json!({"layout": {"grid": {"columns": 5, "rows": 6}}})); + h.app.ui.print.template_name = "Sheet 5x6".into(); + click(&mut h, "button:printSaveTemplate"); + assert_eq!(h.app.session.print_templates.len(), 1); + cmd(&mut h, "print.reset", json!({})); + assert!(has(&h, "button:printTemplateApply-0")); + click(&mut h, "button:printTemplateApply-0"); + assert_eq!(h.app.session.print.layout.grid.columns, 5); + click(&mut h, "button:printTemplateDelete-0"); + assert!(h.app.session.print_templates.is_empty()); +} + +#[test] +fn print_to_pdf_picks_a_file_and_runs_in_the_background() { + let asked = Arc::new(Mutex::new(Vec::::new())); + let requests = asked.clone(); + let bytes = Arc::new(Mutex::new(Vec::new())); + let written = bytes.clone(); + let services = Services { + picker: Some(Box::new(move |req| { + requests.lock().unwrap().push(req); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(vec!["Print.pdf".into()]).unwrap(); + Ok(rx) + })), + write_shared: Some(Arc::new(move |path, data| { + assert_eq!(path, "Print.pdf"); + *written.lock().unwrap() = data.to_vec(); + Ok(()) + })), + ..Default::default() + }; + let mut h = demo(services); + open_print(&mut h); + cmd(&mut h, "print.set", json!({"layout": {"page": {"paper": "5x7", "orientation": "landscape"}}, "job": {"ppi": 100.0}})); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the dialog closes once the print has started"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.last_export_result.is_some()), "the print did not finish"); + let req = asked.lock().unwrap(); + assert_eq!(req[0].file_name.as_deref(), Some("Print.pdf")); + let b = bytes.lock().unwrap(); + assert!(b.starts_with(b"%PDF-")); + let (pw, ph) = media_box(&b); + assert!((pw - 504.0).abs() < 1.0 && (ph - 360.0).abs() < 1.0, "7 x 5 in, got {pw} x {ph} pt"); + assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["print"], true); +} + +#[test] +fn printing_shows_a_row_in_the_activity_stack_and_stops() { + let mut app = LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { write_shared: Some(Arc::new(|_, _| panic!("a stopped print wrote a file"))), ..Default::default() }, + ); + app.session.execute("print.set", &json!({"layout": {"style": "contactSheet"}, "job": {"ppi": 300.0}})).unwrap(); + let ids: Vec<_> = app.session.catalog.photos().take(12).map(|p| p.id.0).collect(); + crate::export_task::start_print(&mut app, &json!({"path": "Stop.pdf", "ids": ids})).unwrap(); + let rows = app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("export", "Printing to PDF")); + assert!(rows[0].cancellable); + app.session.activity.cancel(rows[0].id).unwrap(); + let task = app.export.take().unwrap(); + assert!(task.cancel.load(std::sync::atomic::Ordering::Relaxed)); + drop(task); + assert!(app.session.activity.list().is_empty(), "the row goes with the print"); +} + +#[test] +fn the_print_menu_item_needs_a_photo_and_is_listed() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + assert!(crate::menus::ui_enabled(&app, "dialog.print")); + let empty = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + assert!(!crate::menus::ui_enabled(&empty, "dialog.print")); +} + +#[test] +fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { + for size in [[1400.0, 900.0], [1100.0, 760.0]] { + for style in ["singleImage", "customPackage"] { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, size, 1.0); + h.settle(T); + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "customPackage" { + cmd(&mut h, "print.cell.add", json!({"width": 4, "height": 6})); + } + open_print(&mut h); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let dialog = rect(&h, "dialog:window"); + assert!(screen.contains_rect(dialog), "{style} at {size:?}: dialog {dialog:?} runs off the window"); + for b in ["button:dialogOk", "button:dialogCancel"] { + assert!(dialog.contains_rect(rect(&h, b)), "{style} at {size:?}: {b} is outside the dialog"); + } + } + } +} + +#[test] +fn print_job_colour_controls_reach_the_job_and_the_pdf() { + use lightcraft_engine::print_color::{PrintIntent, PrintMedia, PrintProfile, PrintSharpen}; + let dir = std::env::temp_dir().join(format!("lightcraft-print-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Baryta Paper.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let picked = icc.to_string_lossy().into_owned(); + let services = Services { png: None, pick_display_profile: Some(Box::new(move || vec![picked.clone()])), ..Default::default() }; + let mut h = demo(services); + open_print(&mut h); + // the Print Job panel is at the bottom of the settings column: scroll down to it, as a person would + let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + h.request("ui.scroll", json!({"dy": -600.0}), T); + h.settle(T); + } + let job = |h: &Headless| h.app.session.print.job.color.clone(); + assert_eq!(job(&h).intent, PrintIntent::Perceptual); + assert!(!has(&h, "item:printProfile-other"), "the menu is closed"); + + click(&mut h, "button:printIntent-1"); + assert_eq!(job(&h).intent, PrintIntent::Relative); + click(&mut h, "check:printBpc"); + assert!(!job(&h).black_point_compensation); + click(&mut h, "button:printSharpen-3"); + click(&mut h, "button:printMedia-0"); + assert_eq!((job(&h).sharpen, job(&h).media), (PrintSharpen::High, PrintMedia::Matte)); + click(&mut h, "check:print16bit"); + assert!(!job(&h).sixteen_bit); + drag(&mut h, "drag:printBrightness", 30.0, 0.0); + assert!(job(&h).brightness > 0.0, "{}", job(&h).brightness); + + // a printer profile from the picker + click(&mut h, "combo:printProfile"); + click(&mut h, "item:printProfile-other"); + assert_eq!(job(&h).profile, PrintProfile::Icc { path: icc.clone() }); + + // the PDF uses it: converted pages, the profile named as the output intent, 8 bits + cmd( + &mut h, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}}), + ); + let ids: Vec = h.app.session.catalog.photos().take(1).map(|p| p.id.0).collect(); + let out = dir.join("colour.pdf"); + cmd(&mut h, "print.exportPdf", json!({"path": out, "ids": ids})); + let text = String::from_utf8_lossy(&std::fs::read(&out).unwrap()).into_owned(); + assert!(text.contains("/OutputIntents") && text.contains("/BitsPerComponent 8") && text.contains("/ICCBased")); + + // back to Managed by Printer + click(&mut h, "combo:printProfile"); + click(&mut h, "item:printProfile-adobeRgb"); + assert_eq!(job(&h).profile, PrintProfile::Managed { space: lightcraft_engine::print_color::ManagedSpace::AdobeRgb }); +} + +#[test] +fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { + let mut h = demo(Services { png: None, ..Default::default() }); + let before = h.app.renderer.thumb_jobs_built; + cmd(&mut h, "dialog.print", json!({})); + for style in ["contactSheet", "picturePackage", "singleImage"] { + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "picturePackage" { + // the same photo in cells of different sizes + cmd(&mut h, "print.cell.add", json!({"width": 2.0, "height": 2.5})); + cmd(&mut h, "print.cell.add", json!({"width": 5.0, "height": 7.0})); + } + assert!(h.settle(Duration::from_secs(5)), "{style}: the dialog settles"); + let built = h.app.renderer.thumb_jobs_built; + for _ in 0..60 { + h.step(); + } + assert_eq!(h.app.renderer.thumb_jobs_built, built, "{style}: no thumbnail is rebuilt while nothing changes"); + assert_eq!(h.app.renderer.in_flight(), 0, "{style}"); + } + assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b85c373d5..57ee987d1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -471,7 +471,7 @@ mod tests { #[test] fn thai_folder_name_has_glyphs_in_every_ui_family() { if lightcraft_engine::CRAFT_FONTS.is_empty() { - eprintln!("built without CRAFT_FONTS_DIR: no craft-fonts to check"); + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR: no craft-fonts to check"); return; } let thai = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")).expect("font input includes Thai"); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 67fa4d394..0318dcbb5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -691,8 +691,15 @@ fn star_lit(i: u8, rating: u8, hover: Option) -> bool { /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { + dropdown_within(ui, id, text, font, color, f32::INFINITY) +} + +/// [`dropdown`] at most `max` wide, chevron included: a longer `text` is cut short with …, and +/// shown in full on hover. +pub fn dropdown_within(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32, max: f32) -> Response { let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(text.to_string(), font, color); + let galley = one_line(ui.painter(), text, font, color, max - 20.0); + let elided = galley.elided; let size = vec2(galley.size().x + 20.0, galley.size().y.max(20.0)); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, text)); @@ -700,7 +707,7 @@ pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Co let c = if resp.hovered() { t.text } else { color }; ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c); paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 10.0, r.center().y), vec2(16.0, 16.0)), Icon::ChevronDown, c); - resp + if elided { resp.on_hover_text(text) } else { resp } } #[cfg(test)] diff --git a/docs/architecture.md b/docs/architecture.md index c50066a10..7d1b7a3ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code; the full design notes stay in the local `plan/architecture.md`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`cargo xtask ci` reports the machine and skipped tests; `testkit`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only (`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted @@ -19,7 +19,7 @@ L5 depends on egui, eframe, winit or rfd, and that each crate only uses lower la | L3 | `catalog` (11.0k), `gpu` (5.1k), `preview` (1.3k), `merge` (3.8k), `segment` (2.3k) | Library model and persistence; wgpu compute twin of the pipeline; thumbnail/preview cache and job pool; HDR and panorama merge; SAM 3 segmentation | | L4 | `engine` (53.2k) | Session, command registry (everything is a command), history, render and export jobs, import, Lightroom catalog and preset import, faces index, view-models | | L5 | `ui-egui` (46.9k), `mcp` (2.3k) | The Lightroom-style egui interface (swappable); the MCP server | -| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (2.7k) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling | +| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (3.4k), `testkit` (0.2k, dev-dependency only) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling; test helpers (skips the CI report lists) | ## Data model @@ -63,8 +63,19 @@ dispatch by id, and headless snapshots render the UI without a window. `assets` (every asset attributed, no Adobe formats) and the WASM build. Other workflows build and test FreeBSD and Windows ARM64, lint packaging, and build releases. +`ci` also probes the machine: OS, CPUs, RAM, the GPU adapters of each backend (from the ignored +`gpu_probe` test of `lightcraft-gpu`, run after the workspace tests so it reuses their build; xtask +has no wgpu), the corpus and `CRAFT_FONTS_DIR`. Tests +that need what is missing call `lightcraft_testkit::skip!(need, reason)`, which appends a JSON line to +the file `LIGHTCRAFT_SKIP_LOG` names. The run ends with the machine and the skips by need and reason, +and writes `target/ci/report.json` (commit, dirty flag, machine, capabilities, each step's result and +time, skips) and `target/ci/summary.md` (Markdown for a PR; `--summary-md` prints it). The report holds +no host name, user name or absolute path, and nothing is uploaded. `--hardware` adds a step that runs +the `lightcraft-gpu` tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Quality gates: the CI machine probe, visible skips (`testkit`) and `target/ci/report.json` | | 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) | diff --git a/docs/arw6-compression.md b/docs/arw6-compression.md index 3b65c1d37..334b651f1 100644 --- a/docs/arw6-compression.md +++ b/docs/arw6-compression.md @@ -171,4 +171,22 @@ Raw decode only, 4 CPUs, release build, best of 3 wall-clock (2026-10-10). Metho | ILCE-7RM6 APS-C, Compressed (HQ) | 29.7 MB | 6592x4372 | 0.35 s | | ILCE-7RM6 APS-C, Compressed | 30.9 MB | 6592x4372 | 0.34 s | -The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components. +The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components, and the colour reconstruction over rows. + +2026-10-12 performance pass (output unchanged, bit for bit): the vertical wavelet pass lifts whole rows instead of gathering each column, `0^n 1` codes are counted with one `leading_zeros`, and the colour reconstruction reads rows as slices, in parallel. Same method, best of 5, the previous and the new decoder run alternately on the same machine (4 CPUs, which was busier than on 2026-10-10), best of two rounds: + +| File | Before | After | +|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.56 s | 0.46 s | +| ILCE-7RM6 FF, Compressed | 0.54 s | 0.38 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | +| ILCE-7RM6 APS-C, Compressed | 0.35 s | 0.24 s | + +2026-10-13 (output unchanged, bit for bit): the two halves of each stream, which are independent bit streams, decode in parallel; the vertical wavelet pass runs its rows in parallel; the horizontal pass lifts each row in place (scratch space reused per thread), rows in parallel, instead of allocating two vectors per row. Same method, best of 3, the previous and the new decoder run alternately, on another machine (Ryzen 7 9800X3D, a 4-CPU cgroup quota shared with other builds); CPU is the process CPU time of the decode (all threads): + +| File | Before | After | CPU before | CPU after | +|---|---|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.60 s | 0.48 s | 1.14 s | 0.87 s | +| ILCE-7RM6 FF, Compressed | 0.62 s | 0.48 s | 1.14 s | 0.89 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | 0.50 s | 0.40 s | +| ILCE-7RM6 APS-C, Compressed | 0.31 s | 0.24 s | 0.51 s | 0.42 s | diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 92e6bcb22..c90d008c2 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -241,6 +241,18 @@ as the fitted table does, and the chroma curve has no DNG tag, so it is left out HDR merge of three copies of the photo against the photo alone (200 px, CIE76): mean ΔE 1.07 and 0.47, 95th percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). +A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a +frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama, and an HDR merge from its +inputs, takes its white balance from the frames that are in it: the median R/G and the median B/G of their multipliers +(the mean of the middle two for an even count; frames without usable multipliers, or outside the panorama, do not +vote), written as `AsShotNeutral`, the reciprocal. Any `AsShotWhiteXY` of the source is dropped with it: a reader takes +it first, and the DNG spec has the two exclude each other. So the result is balanced for what the camera measured for +most of its frames, not for whichever frame came first; an HDR panorama's merged brackets vote with their bracket's +median. With no multipliers anywhere (DNG sources) the reference or first frame's tags are kept as before. For frames +balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG renders as before; it matters +when the camera balanced the frames differently, and for a caller that loads frames without a colour hook +(`load_frame`), whose raw frames carried no white balance at all (issue #1144). + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/gaps.md b/docs/gaps.md index 175d767f8..1fa9d705d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -24,12 +24,12 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | | 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 515 of 1,446 Adobe models verified (36%), ~84% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | | 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: identity plate / watermark / page numbers / crop marks / photo info overlays, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | @@ -40,7 +40,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | -| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | +| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | | 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | @@ -75,6 +75,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | +| 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | | 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | | 2026-10-14 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models) | | 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain | @@ -93,6 +95,11 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain | | 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) | | 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision | +| 2026-10-12 | minor | Gap 15: printer colour management lands (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening, 16-bit ICC-tagged PDF with an output intent); page overlays and direct printing remain | +| 2026-10-12 | minor | Gap 15 shrinks: File → Print… is a Print dialog with the four layout styles, cell editing, image settings, guides, page setup and templates; printer colour management, page overlays and direct printing remain | +| 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | +| 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | +| 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | | 2026-10-12 | minor | Gap 25: lossy DNG export (DNG compression 34892) lands; AVIF import and Render to DNG as a catalog command remain | | 2026-10-12 | minor | Gap 22: pen pressure scales brush size and flow (Brush ▸ Pen Pressure) where winit reports a pen force | | 2026-10-12 | minor | Gaps 2 and 10 shrink: 22 more CR3 bodies verified on one CC0 file each (every CC0 CR3 on raw.pixls.us decodes), EOS R6 Mark III white balance read; Canon 34 verified, 259 models, ~59% on a verified body; CR3 gain +6.2 → +0.3 points | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 1ddc4d7bf..7142d4b1a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured; Italian interface catalog added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-.md` @@ -58,15 +58,16 @@ that is promising but is not recorded as review, so the column says `no (communi | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h | -## Other languages LightCraft ships (3) +## Other languages LightCraft ships (4) | Language | Code | UI strings translated | Script | Native review | Status | To `full` | |---|---|---:|---|---|---|---:| | Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | | Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | | Ukrainian | `uk` | 2,383 / 2,398 (99%; 2026-10-12) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | +| Italian | `it` | 2,201 / 2,398 (92%, measured 2026-10-12; the rest is What's New) | Latin (Inter) | no (written with an agent; native-speaker pass pending) | partial (lower-layer errors English) | 3–5 h | -Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai +Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai ~20–35 h on top of the shaping work. @@ -83,14 +84,15 @@ Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norw ## Totals -- Bring the 9 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). +- Bring the 10 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). - Add the 5 missing key languages: ~145–255 h, most of it RTL and complex shaping in egui. -- Reach the target's 16 languages: + ~60–100 h (Italian, Dutch, Polish, Swedish, Norwegian, Thai). +- Reach the target's 16 languages: + ~50–85 h (Dutch, Polish, Swedish, Norwegian, Thai). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English | | 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) | | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | | 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) | diff --git a/docs/localization.md b/docs/localization.md index 7cf92af11..4522beb82 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -32,6 +32,7 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json"); + It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); } ``` diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index cf66efba1..3b069a3c6 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -52,10 +52,10 @@ cited here still exists. Formerly docs/parity.md. | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 77 | 4 | 2 | 1 | 49/52 (94%) | 23/25 (92%) | -| Lightroom Classic extras | 36 | 23 | 27 | 8 | — | 24/26 (92%) | -| **Total** | 413 | 61 | 63 | 35 | 193/201 (96%) | 147/160 (92%) | +| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | +| **Total** | 417 | 61 | 59 | 35 | 193/201 (96%) | 147/160 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **82.6%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 53.4% of 175. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.3%** of 537 in-scope rows — P0 98.0% of 201 · P1 95.0% of 160 · P2 55.7% of 175. ## Top gaps and shortcut notes @@ -79,11 +79,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | | LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -213,7 +213,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A and ILCE-7RM6 distortion above is applied: on the ILCE-7CR the unchanged distortion interpretation misses the camera JPEG at 24 mm (median 1.6 px, p95 6.2 px at 2376 px), and Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | @@ -351,8 +351,8 @@ 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, deghost None–High + overlay, 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. 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, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | +| 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, deghost None–High + overlay, 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, 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-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). 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…` | @@ -474,7 +474,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | | LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | -| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | +| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. No drop-target highlight while files are dragged over the window yet, and a drop imports without the review the menu opens | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | @@ -755,14 +755,14 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | | LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | | LRC-SS-EXPORT | Slideshow export | P2 | 🟡 | `cmd:slideshow.export`, `cmd:slideshow.exportStatus`, `cmd:dialog.slideshowExport`, `cmd:app.slideshowExport`, `path:crates/engine/src/slideshow_export.rs`, `path:crates/codecs/src/video.rs`, `path:crates/ui-egui/src/slideshow_export.rs` | View ▸ Export Slideshow… (and Export PDF / JPEG / Video in Settings ▸ Slideshow): the photos in view as their composed slides (layout, overlays) between the intro and ending screens that are on, in view order. **PDF**: one page per slide at 1024 / 1920 / 3840 px (any 64–8192 by command; page = slide pixels in points, aspect from the aspect preview, "screen" = 16:9), JPEG quality, each page carrying the slide duration and a fade transition for presentation mode, "Automatically show full screen" (`/PageMode /FullScreen`). **JPEG**: `-001.jpg`… in a new folder. **Video**: AV1 (rav1e, fast preset) in MP4 or WebM written by our own muxers, 720p / 1080p (64–2160 by command), 24 / 30 fps (25 / 60 by command); each slide held for its duration (one long sample: variable frame rate) and fades rendered as blended frames (crossfade or through the fade colour); at most 2 hours. Runs in the background with a row in the activity stack (frames or slides done, ✕ cancels); files are written as `.part` and renamed when complete, so a cancelled or failed export leaves nothing; a photo that can't be rendered is skipped with a warning. Missing: H.264 (Classic's codec; patents, no pure-Rust encoder), audio / music in the video (LRC-SS-MUSIC), random order and pan & zoom in exports, the PDF's per-page fades follow the viewer (Acrobat), export presets, Classic's export dialog sizes beyond these, the web build (no export) | -| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing | -| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | -| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | | | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | optional filename captions; no page backgrounds/identity plates | -| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | -| LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ✅ | `cmd:dialog.print`, `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/ui-egui/src/print_view.rs` | File → Print… (⌘P): Single Image, Contact Sheet, Picture Package and Custom Package, one page preview each; also `print.*` commands for the CLI, control channel and MCP; the older File → Contact Sheet PDF stays | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ✅ | `cmd:print.set`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | Zoom to Fill Frame, Rotate to Fit, Photo Border (width, colour; package styles) and Inner Stroke (width in points, colour) in the dialog, drawn in the preview and in the PDF; pixel-tested | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | +| LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; filename captions in the contact-sheet PDF; no identity plate, watermark, page numbers, crop marks or photo info | +| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | @@ -795,6 +795,10 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | +| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | +| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | +| 2026-10-13 | minor | LR-MERGE-HDR: an HDR merge's DNG takes its `AsShotNeutral` from the median of its inputs' vendor white-balance multipliers instead of the reference frame's tags (issue #1144); status unchanged (🟡) | | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung Adobe models verified on one CC0 file each; status unchanged (🟡) | | 2026-10-13 | minor | LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections (Window ▸ Edit Sections, section-header menu, ⌥-click); KEYC-PANELS notes it | | 2026-10-14 | minor | LR-MERGE-HDR ✅ → 🟡: tested on real 67 MP Sony brackets; moving clipped highlights (black-hole stars) and dark-sky ghosts fixed, merge memory cut ~25 %; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain (gap 29). LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`) | @@ -836,6 +840,12 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | | 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | | 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | +| 2026-10-12 | minor | LRC-PRINT-JOB: the Print dialog's Print Job panel sets the profile (Managed by Printer or a printer / paper profile picked from disk), intent, black point compensation, print adjustment, print sharpening and depth | +| 2026-10-12 | minor | LRC-PRINT-JOB: print colour management (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening) and a 16-bit ICC-tagged PDF with an output intent; still 🟡 (no OS print dialog or JPEG output) | +| 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | +| 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | +| 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | +| 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | | 2026-10-12 | minor | LR-MASK-BRUSH: pen pressure scales brush size and flow | | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | @@ -864,6 +874,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 97 Canon CR2, Pentax and Ricoh Adobe models verified on one CC0 file each; status unchanged (🟡) | | 2026-10-13 | minor | LR-LIB-SMARTALBUM: `cmd:library.holdView` and `cmd:library.refresh`: a view can hold the photos it shows until it is refreshed; status unchanged (✅) | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | +| 2026-10-13 | minor | LR-IMP-PROFILES: presets that name a profile apply the imported profile of that name, and Lightroom's basic profiles map to ours (issue #1001); status unchanged (✅) | | 2026-10-12 | minor | LRC-LIB-CATALOG-IMPORT: lasting destination/result, offline records and virtual copies count as added; redundant missing-file probes and staged photo copies removed. Status and summary counts unchanged. | | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index bf81c93bf..f02d91ca1 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -15,13 +15,13 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **515** (36%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **675** (47%) | measured from the format rules (631 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **218** (15%) | measured | +| Decoded from sensor data and **verified** on a real file | **545** (38%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **651** (45%) | measured from the format rules (631 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **212** (15%) | measured | | **Unsupported** (not imported, refused or decodes wrongly) | **38** (3%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 159 / 563 / 24 / 8 of 754 | measured | -| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~93%** | estimated: measured classes × written usage weights × per-format confidence | -| …of which on a body we have verified | **~84%** | estimated, same weights | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 182 / 542 / 22 / 8 of 754 | measured | +| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~94%** | estimated: measured classes × written usage weights × per-format confidence | +| …of which on a body we have verified | **~85%** | estimated, same weights | | Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | | **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) | @@ -53,6 +53,17 @@ committed table is stale. at 400 px; the files are not committed). Bulk-check body lists were matched to Adobe names through the raw.pixls.us folder names. Checks without a body list (≈ 60 NEFs, 35 CR2 bodies for the CFA layout) are not counted as verified. +- **2026-10-12 public sweep:** 30 further models on 40 CC0 files, independently downloaded and + decoded on main `25ef65c8`; [raw/coverage-sweep.tsv](raw/coverage-sweep.tsv) records the URLs, + SHA-256, dimensions, FNV-1a sensor hashes and render/reference luminance correlations (0.6801 + to 0.9998). The hashes pin LightCraft's own output. The regression test is + `crates/raw/tests/coverage_sweep_corpus.rs`; downloads are in `RAW_SAMPLES`. Ten references are + embedded TIFF images; the rest are JPEGs. FFF TIFF previews are not display-ready and check + image structure only. Eleven candidate models were excluded for identity, provenance or + missing/mismatched reference images. D6 / Z 6 / Z 7 verification covers compressed NEF only; + uncompressed remains preview-only (#671), as do Z 8 HE / HE* files. Own-matrix phone, drone + and other DNGs can decode correctly while rendering darker than their camera JPEG (#1277). + Verified means sensor data decoded, not colour parity. - **Usage weights** ([raw/usage-weights.tsv](raw/usage-weights.tsv)): maker buckets from 2025 market data (Nikkei production: Canon ~36%, Sony ~29%, Nikon ~14%, Fujifilm ~12% of mirrorless; BCN retail: Sony 29.9%, Canon 27.4%, Nikon 15.1%), adjusted for the DSLR installed base and for the makers named @@ -82,7 +93,7 @@ committed table is stale. | Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF | | Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | -| DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | +| DJI | 21 | 4 | 17 | 0 | 0 | DNG | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | | Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | | **All** | **1,446** | **259 (18%)** | **919 (64%)** | **232 (16%)** | **36 (2%)** | | @@ -146,9 +157,9 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) | | DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | -| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL only; white-level pile-up fix #548 | +| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | -| 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; FFF neutral-matrix colour remains unverified | | 3FR / FFF | Hasselblad compression | ⬜ | preview | | ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | | IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | @@ -276,6 +287,7 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | | 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | | 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | | 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (`olympus_samsung_corpus.rs`): 254 verified, 932 unverified, 224 preview; OM / Olympus 3% → 6% (5% on verified bodies); use-weighted 85% and 42% unchanged; duplicated summary rows from a merge removed | diff --git a/docs/raw/coverage-sweep.tsv b/docs/raw/coverage-sweep.tsv new file mode 100644 index 000000000..8e3b2449a --- /dev/null +++ b/docs/raw/coverage-sweep.tsv @@ -0,0 +1,45 @@ +# CC0 raw.pixls.us camera-coverage check, 2026-10-12; main 25ef65c8. File SHA-256 matches the public listing. +# FNV-1a-64 pins LightCraft's own complete decoded samples, row-major little-endian, including masked borders. +# Default renders at up to 1200 px; Pearson r on Lab L* at 200 px after orientation/aspect alignment, threshold 0.6. +# Hasselblad PreviewTIFF references check image structure only. Own-matrix DNG tone remains subject to issue #1277. +maker camera id file url sha256 width height cpp samples fnv1a64 luma_r reference notes +Apple iPhone 6s Plus 915 coverage-915.dng https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG 845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c 4032 3024 1 u16 17e4d3c620d593c9 0.9712 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -10.109 +Apple iPhone 7 Plus 939 coverage-939.dng https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e 4032 3024 1 u16 d0095a8b553f0f8a 0.9904 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -11.913 +Apple iPhone XS 3372 coverage-3372.dng https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng 50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6 4032 3024 1 u16 a8e819ac4e5beb2a 0.9805 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -14.751 +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) 3009 coverage-3009.dng https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG 7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec 5568 3648 1 u16 b207313fa7dee601 0.9781 embedded JPEG 16bit (3:2); comparison Normal/crop-render; mean dL -8.41 +DJI DJI Mavic 3 (Hasselblad L2D-20c) 5140 coverage-5140.dng https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4 5376 3956 1 u16 2c78ceb8cb893ff1 0.8618 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL 0.418 +DJI DJI Mavic 4 Pro (DJI FC9287) 8041 coverage-8041.dng https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG 901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0 8192 4608 1 u16 d1babb4646097d6f 0.6801 embedded JPEG 16bit (16:9); reference has a different distortion/crop; comparison Normal/crop-render; mean dL 2.583 +DJI DJI Mini 2 4785 coverage-4785.dng https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG 21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851 4000 3000 1 u16 d1e66e08f335b55b 0.9328 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -9.016 +DJI DJI Mini 2 4898 coverage-4898.dng https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d 4000 2250 1 u16 acaab4fe42d3dc58 0.9716 embedded JPEG 16bit (16:9); comparison Normal/crop-render; mean dL -8.153 +Google Pixel 4 XL 3502 coverage-3502.dng https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng 0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54 3700 2774 1 u16 d121f233fdbaff2c 0.9143 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -0.351 +Google Pixel 4a Rear Wide Camera 4176 coverage-4176.dng https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng 11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be 4032 3024 1 u16 ac056154d440cb2d 0.9515 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -2.361 +Google Pixel 6 Pro Rear Main Camera 5961 coverage-5961.dng https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng 3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d 4080 3072 1 u16 96631250f12729ad 0.944 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -0.402 +Google Pixel 8 Pro Wide Camera* 7751 coverage-7751.dng https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng 45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5 8160 6144 1 u16 5e30a4d3bfc914b3 0.9485 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -5.099 +Hasselblad CFV-50c 7217 coverage-7217.3fr https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0 8374 6304 1 u16 14e60b95775930f2 0.9984 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -14.199 +Hasselblad CFV-50c 7218 coverage-7218.fff https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932 8374 6304 1 u16 db63ffcd176381ee 0.9443 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 28.828 +Hasselblad H5D-40 1640 coverage-1640.fff https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff 209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832 7410 5586 1 u16 1d34f6f4e45b5eab 0.9574 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 29.541 +Hasselblad H5D-50c 1639 coverage-1639.fff https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff 1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0 8374 6304 1 u16 02a189d2a6b78d4e 0.9453 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 28.943 +Hasselblad X1D 2058 coverage-2058.3fr https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR 395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3 8384 6304 1 u16 0b480efe4d2ed7c2 0.9753 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -9.059 +Hasselblad X1D II 50C 3874 coverage-3874.fff https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff 8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794 8384 6304 1 u16 c7372897d70da8a8 0.9436 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 22.474 +Hasselblad X1D II 50C 3941 coverage-3941.3fr https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR 809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47 8384 6304 1 u16 3c3a77bf67197196 0.9982 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -11.176 +Hasselblad X2D 100C 6568 coverage-6568.fff https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff 44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc 11904 8842 1 u16 849906d491fba55a 0.9196 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 26.781 +Hasselblad X2D 100C 6565 coverage-6565.3fr https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280 11904 8842 1 u16 c07a267c0bc60a5f 0.9962 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -2.632 +Nikon D6 4726 coverage-4726.nef https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF 3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233 5584 3728 1 u16 272425c271bb0d76 0.9557 embedded JPEG 12bit 12bit compressed (Lossy (type 2)) (3:2); comparison Normal/crop-render; mean dL 0.037 +Nikon D6 4720 coverage-4720.nef https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF 827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f 5584 3728 1 u16 1d6a9f10a502fb14 0.9581 embedded JPEG 14bit 14bit compressed (Lossless) (3:2); comparison Normal/crop-render; mean dL 0.048 +Nikon D6 4721 coverage-4721.nef https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF 081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838 5584 3728 1 u16 2d6c8af38e4a0ed4 0.9592 embedded JPEG 14bit 14bit compressed (Lossy (type 2)) (3:2); comparison Normal/crop-render; mean dL 0.043 +Nikon Z 6 3584 coverage-3584.nef https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF 3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886 6064 4040 1 u16 a63b22e83bb7c82f 0.8749 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 0.327 +Nikon Z 6 3581 coverage-3581.nef https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF 8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02 6064 4040 1 u16 1e23d74a11c01a97 0.8752 embedded JPEG 14bit 14bit compressed (3:2); comparison Normal/crop-render; mean dL 0.353 +Nikon Z 6 3582 coverage-3582.nef https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31 6064 4040 1 u16 0a0bc73568e125e0 0.8755 embedded JPEG 14bit 14bit lossless compressed (3:2); comparison Normal/crop-render; mean dL 0.464 +Nikon Z 7 2792 coverage-2792.nef https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded 8288 5520 1 u16 c989690cb3e19ef9 0.7417 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.417 +Nikon Z 7 2789 coverage-2789.nef https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e 8288 5520 1 u16 627867ac6a8dd4ad 0.7412 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.299 +Nikon Z 7 2790 coverage-2790.nef https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114 8288 5520 1 u16 8a52e6f3d8ff64dd 0.7389 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.585 +Nikon Z 8 6617 coverage-6617.nef https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40 8280 5520 1 u16 716e1158bab28b76 0.9563 embedded JPEG 8bit 8bit compressed (3:2); comparison Normal/crop-render; mean dL 2.044 +Parrot Anafi 2912 coverage-2912.dng https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6 5344 4016 1 u16 07a188c932a25a31 0.9895 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -3.924 +Samsung Galaxy S21 Ultra Rear Main Camera 6728 coverage-6728.dng https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng 5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e 4000 3000 3 u16 16c5d6faed83390d 0.9912 embedded JPEG 4:3; comparison Rotate180/crop-render; mean dL -10.83 +Samsung Galaxy S22 Ultra Rear Wide Camera 6261 coverage-6261.dng https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng 53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581 4000 3000 3 u16 7cbe612adc5575b4 0.9739 embedded JPEG 4:3; comparison Normal/crop-render; mean dL -20.487 +Samsung Galaxy S23 Rear Wide Camera 6697 coverage-6697.dng https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng 1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067 8160 6120 3 u16 b3da1535e2e31e9f 0.9418 embedded JPEG 4:3; comparison Normal/crop-render; mean dL -6.585 +Samsung Galaxy S7 Edge 1297 coverage-1297.dng https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng 843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561 4032 2268 1 u16 7e99d7da67022e5a 0.8677 exiftool:PreviewTIFF 16bit (16:9); comparison Normal/crop-render; mean dL 5.772 +Samsung Galaxy S8 2645 coverage-2645.dng https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng 6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2 4032 3024 1 u16 b70ca9a743283754 0.9884 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -4.112 +Samsung Galaxy S8+ 2310 coverage-2310.dng https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng 3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c 4032 3024 1 u16 c3a20523ad3b18cf 0.986 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL 2.437 +Sigma fp 7273 coverage-7273.dng https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG 0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9 6064 4042 1 u16 f7b92f1904d6cc31 0.9754 embedded JPEG 14bit (3:2); comparison Normal/crop-render; mean dL 7.1 +Sigma fp L 6949 coverage-6949.dng https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG 8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70 9536 6344 1 u16 6896f73ec6b49231 0.9998 embedded JPEG 14bit (3:2); comparison Normal/crop-render; mean dL 1.597 diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index b228e62cc..8bf7c67a8 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -97,16 +97,16 @@ Apple iPad Pro 12.9-inch (2018 model) DNG 2018 unverified DNG decoder (uncompres Apple iPad Pro 13-inch M4 (2024 version) Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad mini (6th Generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 6s DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 6s Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 6s Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 7 Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 7 Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 8 Plus DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (2020 version) DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone X DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone XR DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone XS DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone XS DNG 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone XS Max DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad Air (5th generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (3rd generation) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -274,9 +274,9 @@ Casio EXILIM EX-ZR4100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, loss Casio EXILIM EX-ZR5000 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-ZR5100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Contax N Digital RAW 2003 preview RAW is opened from its embedded JPEG only -DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI AIR 2S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mini 2 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mini 2 DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Air 3 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3 Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mini 3 Pro DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -287,13 +287,13 @@ DJI DJI Mavic 3 Cine (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncom DJI DJI Mavic 3 Pro Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Osmo Pocket 3 D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Osmo Action 5 Pro DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 4 Pro (DJI FC9284) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Epson R-D1 ERF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -407,7 +407,7 @@ Google Pixel 3 XL DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, De Google Pixel 3 XL Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 3 XL Front Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4 XL DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4 XL DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5 Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -415,7 +415,7 @@ Google Pixel 6 Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, Google Pixel 6 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 6 Pro Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Pro Rear Main Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 6 Pro Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -426,7 +426,7 @@ Google Pixel 7 Pro Telephoto Camera DNG 2022 unverified DNG decoder (uncompresse Google Pixel 7 Pro Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Pro Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4a Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4a Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Main Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Ultra Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -442,7 +442,7 @@ Google Pixel 8 Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, Google Pixel 8 Pro Front Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Telephoto Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Ultrawide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 8 Pro Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Pro Wide Camera* DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 8a Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -502,7 +502,7 @@ Hasselblad CF-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-39 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFV 3FR 2008 preview 3FR is opened from its embedded JPEG only -Hasselblad CFV-50c 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CFV-50c 3FR 2008 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H2D DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Hasselblad H2D-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H2D-39 3FR 2008 preview 3FR is opened from its embedded JPEG only @@ -515,18 +515,18 @@ Hasselblad H3DII-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H3DII-50 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-40 3FR 2010 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-60 3FR 2010 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-40 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-40 3FR 2012 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-50 3FR 2012 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-50c 3FR 2013 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-50c 3FR 2013 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-60 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H6D-100c 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad HV ARW 2013 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Hasselblad Lunar 3FR 2012 verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Hasselblad Stellar II 3FR 2015 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D 3FR 2015 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D 3FR 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad X2D II 100C (**) 3FR 2024 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D II 50C 3FR 2018 preview 3FR is opened from its embedded JPEG only -Hasselblad X2D 100C 3FR 2021 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D II 50C 3FR 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C 3FR 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Huawei Honor 20 Pro Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Telephoto Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -723,7 +723,7 @@ Nikon D40 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sens Nikon D40x NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D50 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D60 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon D6 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D6 NEF 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon D70 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D70S NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D80 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) @@ -766,12 +766,12 @@ Nikon Z 50 II NEF 2024 verified corpus: crates/raw/tests/body_corpus.rs (pinned Nikon Z 5 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 5 2 NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 5 2C NEF 2025 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 6 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 6 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon Z 6 II NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 6 III NEF 2023 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 7 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 7 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) Nikon Z 7 II NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) -Nikon Z 8 NEF 2022 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 8 NEF 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only Nikon Z 9 (*See note) NEF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z fc NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z f NEF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -1007,7 +1007,7 @@ Panasonic LUMIX DC-S1M2ES (Final Support) RW2 2024 verified PR #215: 178 raw.pix Panasonic LUMIX DC-S1M2 RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-ZS300 (DC-TZ300, DC-TX3) RW2 2025 unverified Panasonic decoder, every raw format Panasonic LUMIX DC-L10 RW2 2025 unverified Panasonic decoder, every raw format -Parrot Anafi DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Parrot Anafi DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Parrot ANAFI Ai DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax 645D DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax 645Z DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) @@ -1127,16 +1127,16 @@ Samsung Galaxy S21 Rear Main Camera DNG 2020 unverified DNG decoder (uncompresse Samsung Galaxy S21 Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S21 Ultra Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 Edge DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S7 Edge DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S8+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S7 Edge DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S9 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S9+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Z Fold3 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1154,11 +1154,11 @@ Samsung Galaxy S22 Ultra Front Camera DNG 2021 unverified DNG decoder (uncompres Samsung Galaxy S22 Ultra Rear Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S23 Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23+ Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1230,8 +1230,8 @@ Sigma BF DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JP Sigma DP1 X3F 2007 preview X3F is opened from its embedded JPEG only Sigma DP1s X3F 2008 preview X3F is opened from its embedded JPEG only Sigma DP2 X3F 2008 preview X3F is opened from its embedded JPEG only -Sigma fp DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Sigma fp L DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sigma fp DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sigma SD9 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD10 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD14 X3F 2007 preview X3F is opened from its embedded JPEG only diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index b270b5582..6e60a6828 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -533,3 +533,33 @@ Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Mamiya ZD verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Apple iPhone 6s Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone 7 Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone XS verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 3 (Hasselblad L2D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 4 Pro (DJI FC9287) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mini 2 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4 XL verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4a Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 6 Pro Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 8 Pro Wide Camera* verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Hasselblad CFV-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-40 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D II 50C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Nikon D6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 7 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only +Parrot Anafi verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S21 Ultra Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S22 Ultra Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S23 Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S7 Edge verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index dbc8090c7..ed32fc85d 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -1,30 +1,143 @@ -# Sony embedded distortion corrections +# Sony embedded lens corrections The **ILCE-7RM6** (A7R VI) has its own conversion, with other conventions measured against its camera JPEGs: [Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md). On ILCE-7RM6 photos the formula below misses the 24–70 GM II by up to 1.43 px at 1440 px while fitting the 100 mm and 200–600 better, and no single unit fits every lens under either convention; that page's *Two conventions* section has the comparison and what would settle it. -LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of -Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected, -even when they contain a table with the same layout. -It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control, -CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. +Sony ARWs record three lens corrections per shot in the raw image IFD: shading (vignetting), lateral chromatic +aberration (CA) and distortion, each as a camera setting and a radial table, with copies in the encrypted +`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and **applies one**: the distortion table +of the **ILCE-7RM4A (A7R IVA)** in Bayer ARWs with native 3:2 framing, independently validated with the +FE 24–105mm F4 G OSS and FE 200–600mm F5.6–6.3 G OSS (the ILCE-7RM6's distortion is applied too, by its own +conversion above). Every other model, +and shading and CA on every model, are decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image +(`RawInfo::lens_metadata` / `RawImage::lens_metadata`, types in `crates/raw/src/lens_metadata.rs`, decoder in +`crates/raw/src/vendor/arw_lens.rs`) for diagnostics and for the validation work described below. -This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded. -The enabled and independently validated model is ILCE-7RM4A with the FE 24–105mm F4 G OSS and -FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may -retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry -only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected. -The camera's distortion Off setting does not erase its table. Newly imported photos use LightCraft's existing -embedded-profile default (enabled); the profile correction control can disable or adjust it. +The applied distortion is converted into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction +control, CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. Linear YCbCr ARWs +and non-3:2 camera crops are excluded pending validation. An aspect crop may retain the full-frame radial +normalization; the decoder does not guess that relationship. Tables of other lengths and rejected tables remain +uncorrected, and a table found only in the encrypted `SR2SubIFD` is never applied. The camera's distortion Off +setting does not erase its table. Newly imported photos use LightCraft's existing embedded-profile default +(enabled); the profile correction control can disable or adjust it. + +## What is decoded + +| Component | Raw-IFD setting / table | `SR2SubIFD` setting / table | Accepted layout | +|---|---|---|---| +| Shading | `0x7031` / `0x7032` | `0x797c` / `0x797d` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | +| Lateral CA | `0x7034` / `0x7035` | `0x797f` / `0x7980` | SHORT\[1\]; SSHORT\[33\]: 32, then two 16-sample curves | +| Distortion | `0x7036` / `0x7037` | `0x7981` / `0x7982` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | + +- **Bounded, exact reading.** A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses: + its first occurrence, within the reader's limits (8,192 entries per directory, a total value budget). The directory + is also scanned, in either byte order and in BigTIFF, for diagnostics only: a tag that occurs more than once is + flagged (`raw_ifd_repeated`, `sr2_repeated`), and an entry the reader left out is reported with its reason (values + outside the file, an unknown type, or past the reader's limits) rather than treated as absent. `SR2SubIFD` fields + are read straight from their first entry. A setting may also be a nonnegative SSHORT (the documented editor type + differs from the type the cameras store). Anything else is refused with its reason (`SonyFieldError`: field type, + count, leading sample count, values outside the file or block, past the reader's limits, a negative setting). + Nothing is allocated from a count in the file. +- **Raw IFD first, `SR2SubIFD` as a checked fallback.** The raw IFD's copy wins. The `SR2SubIFD`'s copy is used only + when the raw IFD lacks the tag; a malformed raw-IFD copy is reported, never silently replaced. When both copies are + well-formed their agreement is recorded (`copies_agree`), and on disagreement the raw IFD's copy is used. The block + is decrypted (by the existing `vendor/sr2.rs`) for every ARW that has one, so the check always runs; a block that + can't be found or decrypted gives no copies. Locating it selects the `SR2Private` directory's offset, length and + key exactly as the TIFF reader would (the reader's own directory layout, 8,192-entry limit, readable-entry rules + and value budget, then the first readable occurrence of each), but decodes only each field's first value, so the + directory's other entries cost no memory however large they claim to be; the block itself is limited to 1 MiB. The + plain tag numbers are not looked up inside the `SR2SubIFD`. +- **Settings keep their number and a documented meaning:** shading 256 Off, 257 and 272 Auto, 511 unavailable; CA + 0 Off, 1 Auto, 255 unavailable; distortion 0 Off, 1 Auto, 17 Auto fixed by the lens, 255 unavailable. Any other value + is `Unknown(value)`, never Off (the A7CR's Low shading setting has no documented number). Values are not read as + bit masks. Off does not mean absent: the table is still recorded. +- **Only the 16-sample (CA: 2 × 16) layout is interpreted.** Many bodies store 11 (CA: 22) samples padded into the + same 17 (33) values: older compacts and bodies such as the DSC-RX100 III and ILCE-3500, and the ILCE-7M4's APS-C + captures (its full-frame captures carry 16). Those tables are refused as `CountPrefix(11)` and stay + uninterpreted. An all-zero table is kept: it is a valid no-op, not a sign of an unsupported lens. +- **Framing evidence travels with the tables:** the stored frame (padding included), Sony's crop tags and the DNG + default crop as recorded, the image crop, the reference rectangle the distortion conversion is centred on and + normalized to (the default crop when present, else the image crop), compression, whether the data is linear + YCbCr (lossless M/S), and whether both crops are native 3:2. +- **Per-component status.** `Applied` (converted into an opcode), `NoTable`, `NotValidatedComponent` (shading and CA + on every body), `NotValidatedModel`, `NotValidatedMode` (linear YCbCr, non-3:2 framing, or a distortion table found + only in the `SR2SubIFD`), `Neutral` (all-zero table in the validated scope) and `Rejected` (the validated + conversion refused the table). A readable table never implies a usable correction; only `Applied` components + become opcodes. + +The applied path is unchanged: the validated conversion still receives the raw IFD's `0x7037` table exactly as the +TIFF reader parsed it, with the same model, framing and fit checks, and produces the same warp. A repeated tag keeps +the reader's choice and is only flagged, and a table the reader never reads (past its entry limit) is reported but +never applied, so already-catalogued photos, their previews and their stored lens data stay consistent with a fresh +decode. + +## What each component still needs + +The following summarizes an investigation on the ILCE-7CR (A7CR): an owner's collection of 131 raws with four +lenses (Voigtländer APO-LANTHAR 28mm F2 and 50mm F2, both manual focus; Sony FE 24–50mm F2.8 G; Tamron 35–150mm +F2–2.8), plus the three public raw.pixls.us A7CR samples. No private photographs, names or derived images are +part of this repository. + +- **Inventory.** All 131 raws carry all three tables in both the raw IFD and the `SR2SubIFD`, and the copies agree + in every file; all four lenses provide tables, the manual-focus ones included. Shading is 272 (Auto) and CA 1 + (Auto) everywhere; distortion is 17 (Auto, fixed by the lens) on the nine FE 24–50 files and 0 (Off) on the rest. + Every image crop is 9504 × 6336 at (32, 20), in uncompressed, compressed and lossless-L (padded to 9728 × 6656) + storage. The decoder reproduces this inventory exactly (see *Local inventory check*). +- **Distortion.** The table layout and crop tags match the A7R IVA's, but its interpretation applied unchanged does + not reproduce the A7CR camera JPEG at 24 mm: at 2376 px wide, median / 95th-percentile feature residuals were + 1.61 / 6.17 px (12.98 / 51.19 px uncorrected, 6,539 matches), with a signed radial pattern (about +1.7 px at + radii 0.25–0.5, −7.4 px at 0.8–1) that no translation removes. At 50 mm it agrees (0.23 / 1.05 px, from + 2.21 / 12.25 px). Scaling the raw table by about 10/9, or using the MakerNote's distortion array instead, brings + 24 mm to about 0.37 / 1.10 and 0.33 / 0.97 px. Those are exploratory comparisons on two files against the camera + JPEG, not a model: the MakerNote values are roughly 1.11 times the raw table without one exact ratio, and the + A7R IVA was validated against a different reference renderer (Imaging Edge exports). Needed: A7CR Imaging Edge + On/Off exports at 24 and 50 mm and with another lens, compared with the raw table at its fixed scale; a decision on + the reference (camera JPEG or Imaging Edge); then ILCE-7CR added to the validated models for the full-frame Bayer + 3:2 mode only. Neither a 10/9 multiplier nor the MakerNote table is used. +- **Shading.** The layout decodes, but no public specification gives the sample-to-gain function, its radius + convention on the A7CR, or whether the raw samples already contain part of the correction. Brightness ratios on six + natural scenes show no consistent missing peripheral brightening. Needed: evenly lit flat fields with shading + Off / Auto / Low at several apertures and focal lengths, raw CFA ratios between them, and Imaging Edge On/Off + exports, to fix the gain function and processing stage and rule out double correction. +- **Lateral CA.** A public analysis reads the table as a red, then a blue curve relative to green, at a 2⁻²¹ + relative radial scale. On three distortion-Off files that candidate reduced red and blue feature residuals + modestly (for example 0.35 → 0.29 px median for red with the 28 mm), which supports investigating it but certifies + neither scale, interpolation nor processing stage. Two product changes come before it: embedded CA must be + independent of the distortion amount (today `pipeline::optics` scales the whole embedded per-plane warp with the + profile distortion control), and the red and blue planes must be resampled in camera RGB, before the camera colour + model mixes them (`engine::files::load_bytes_now` applies the colour model before the optics stage). Then + validation on full-resolution achromatic edges. +- **Other modes.** APS-C, aspect crops and lossless M/S (linear YCbCr) captures were not measured and stay + excluded. + +## Local inventory check + +`crates/raw/tests/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a +directory of ARWs it decodes each from its headers and prints its record (settings, sources, copies' agreement, +sample ranges, statuses); `LIGHTCRAFT_SONY_LENS_FULL=1` also compares header and full decode. With +`LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory it checks every file's table counts, sizes, sample counts, +minima and maxima, raw-IFD versus `SR2SubIFD` agreement, settings (numbers and labels), stored size, compression and +crop tags. A file with a row must decode, and every file must have a row, whether it decodes or not. Any decode +error fails the check except an unsupported variant, which is counted and must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` +(default 0). On the 131 A7CR raws above, all 131 inventory rows reproduce and every file decodes. On 133 public Sony +ARWs (127 raw.pixls.us samples from 100 bodies, four corpus copies and two files attached to a public issue), 110 carry +a record, 14 decode without one, and 9 are unsupported variants (eight packed ARWs of older bodies and one ARW +without a CFA image); header and full decode agree on the 124 that decode, and no record has disagreeing copies. ## Clean-room evidence -The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) identifies -`0x7037` as 17 signed 16-bit integers. The first value is the count, 16, followed by radial samples. -No external photo decoder implementation or Adobe lens profile was consulted. -Sony Imaging Edge Edit 4.1 was used only as a black-box reference on local scratch copies. +The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) names the raw-IFD tags +`0x7031` to `0x7037` (VignettingCorrection / VignettingCorrParams, ChromaticAberrationCorrection / +ChromaticAberrationCorrParams, DistortionCorrection / DistortionCorrParams) with their setting values, and gives the +tables as 17 or 33 signed 16-bit integers whose first value is the count. The +[Sony SR2SubIFD documentation](https://exiftool.org/TagNames/Sony.html#SR2SubIFD) names the encrypted copies +`0x797d`, `0x7980` and `0x7982`; the setting tags next to them (`0x797c`, `0x797f`, `0x7981`) are unnamed there and +were identified by inspecting files, where they equal the raw-IFD settings. The corpus test's expected tables are the +values ExifTool, run as a black-box tool, prints for the public samples. No external photo decoder implementation, +lens database or Adobe lens profile was consulted. Sony Imaging Edge Edit 4.1 was used only as a black-box reference +on local scratch copies. The observed inverse mapping, with radius `r` normalized to the default crop's half diagonal, is: @@ -50,7 +163,8 @@ so the warp stays on the optical centre either way. On the A7R IVA that framing pixels left of Sony's exports (eight pixels in a 2376-pixel-wide export). The main raw-IFD table is important: the similarly named MakerNote table has different values and -must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files. +must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files, and the +`SR2SubIFD` copy is never converted. ## Validation @@ -95,7 +209,18 @@ The distortion geometry remains aligned while every body's image framing follows Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency, -and preservation through DNG export. No private photographs or reference TIFFs are committed. +and preservation through DNG export. The decoder's synthetic tests (`crates/raw/src/vendor/arw_lens.rs`) add both +byte orders and BigTIFF, exact SHORT / SSHORT types, negative samples, missing, short, long, huge and zero counts, +wrong leading counts, values outside the file, a table past the TIFF reader's entry limit (not applied), repeated +tags (the reader's copy kept, as before), unknown and negative settings, raw-IFD versus +`SR2SubIFD` precedence and agreement, the correct `SR2SubIFD` tag numbers, a wrong decryption key, hostile entries +inside the decrypted block, linear YCbCr, framing evidence (Sony's crop at x = 0 against the default crop, agreeing +crops, padded storage, aspect crops, crop tags outside the frame), all eight EXIF orientations leaving the record +unchanged, and neutral, out-of-range and folding tables. On a small classic-TIFF fixture in both byte orders, every +truncation and four mutations of each byte outside the pixel data (set to 0x00 and 0xff, top and low bit flipped) +decode or fail without a panic. `tests/sr2_heap.rs` measures the peak heap of decoding a 16 MiB file whose +`SR2Private` directory holds eight fields aliasing the whole file: 37 kB, against 134 MB when those fields were +decoded. No private photographs or reference TIFFs are committed. ## Camera coverage and regression boundary @@ -104,27 +229,35 @@ model names, and every other model remain unsupported by this distortion decoder numbers and table lengths do not establish the scale, interpolation or crop normalization on another body. Support can expand after independent geometry validation; do not add model-name prefix matching. -The current `cargo xtask corpus --download` manifest contains 23 Sony CC0 samples across 17 bodies, -including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm). All 23 files have -pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_to_validated_models`: +The current `cargo xtask corpus --download` manifest contains 25 Sony CC0 samples across 18 bodies, +including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm), each with a pinned SHA-256 that the +download checks. `sony_embedded_distortion_is_limited_to_validated_models` checks 23 of them (the two ILCE-7CM2 +codings have their own test), and `sony_lens_records_of_the_public_samples` verifies all 25 against their pins +(`SONY_SAMPLES` in `crates/raw/tests/corpus.rs`, itself anchored by a pinned digest and compared with the manifest) +before decoding. **No CI job fetches the corpus**: these fixture tests run only where it has been downloaded, and +otherwise skip each absent file. `LIGHTCRAFT_CORPUS_REQUIRED=1` makes `sony_lens_records_of_the_public_samples` +fail on any absent pinned file instead. -| Camera | Corpus samples | Raw-IFD table observed | Expected correction | +| Camera | Corpus samples | Decoded record: tables, settings (shading / CA / distortion) | Applied | |---|---|---|---| -| ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp | -| ILCE-9M2 | Compressed (3989) | 16 samples | None | -| ILCE-7M3 | Compressed, uncompressed | 16 samples | None | -| ILCE-7M4 | 14-bit, lossless L/M/S | 11 samples in the 14-bit file; 16 in L/M/S | None | -| ILCE-7RM2 | 12-bit uncompressed | 16 samples | None | -| DSC-RX100 | One | No raw-IFD table | None | -| DSC-RX100M3 | One | 11 samples | None | -| ILCE-7CR | Compressed, lossless L/M | Not used; model excluded | None | -| ILCE-7RM4 | 14-bit compressed | Not used; model excluded | None | -| DSLR-A500, DSLR-A700, SLT-A33 | One each | Not used; model excluded | None | -| ILCE-3500, ILCE-7S | 5600 K, Shade respectively | Not used; model excluded | None | -| ILCE-7SM2, DSLR-A580 | 16:9 framing, one each | Not used; model excluded | None | -| DSLR-A900 | Packed 12-bit | Not used; model excluded | None | +| ILCE-7RM4A | Compressed (4822) | All three tables; 257 / 1 / 17 | One distortion warp | +| ILCE-9M2 | Compressed (3989) | All three; 257 / 1 / 0 | None | +| ILCE-7M3 | Compressed, uncompressed | All three; 257 / 1 / 0 | None | +| ILCE-7M4 | 14-bit, lossless L/M/S | L/M/S: all three, 272 / 1 / 17; the 14-bit file (APS-C framing, 4608 × 3072): settings only, its 11-sample tables are not interpreted | None | +| ILCE-7RM2 | 12-bit uncompressed | All three; 256 / 0 / 1 | None | +| DSC-RX100 | One | Shading, from the `SR2SubIFD` (the raw IFD has no tables); CA and distortion in the 11-sample layout | None | +| DSC-RX100M3 | One | Shading; CA and distortion in the 11-sample layout | None | +| ILCE-7CR | Compressed, lossless L/M | All three; 272 / 1 / 0 | None | +| ILCE-7RM4 | 14-bit compressed | All three; 257 / 1 / 17 | None | +| ILCE-3500 | 5600 K | Settings only (unavailable) | None | +| ILCE-7S | Shade | An all-zero CA table; settings unavailable | None | +| ILCE-7SM2 | 16:9 framing | All-zero shading and CA tables; settings unavailable | None | +| DSLR-A500, DSLR-A700, SLT-A33, DSLR-A580, DSLR-A900 | One each | No record | None | -These are **model-boundary regression checks, not new geometric validation of those models**. In particular, +These are **model-boundary regression checks, not new geometric validation of those models**. +`sony_lens_records_of_the_public_samples` also checks the exact tables, settings, provenance and framing evidence of +the three A7CR codings and the A7R IVA sample, and that across the Sony corpus only the A7R IVA's distortion is ever +applied. In particular, refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S files are linear YCbCr and remain excluded independently of the model check. Header and full decode must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 90295104c..07ae89fd7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (RAW: 82% of Adobe's models decode, 36% verified) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -12,7 +12,7 @@ have their own documents: [raw-parity.md](raw-parity.md), [file-format-parity.md | Number | Value | Kind | |---|---:|---| -| **Feature breadth**, checklist | **81.1%** of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | +| **Feature breadth**, checklist | **81.6%** of 532 in-scope rows (P0 98.0%, P1 95.2%, P2 50.0%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | | **Feature breadth**, weighted by use | **~77%** | estimated: feature-area table below | | **Ready for real work** (full target) | **~61%** (range 56–66%) | estimated: weighted sum over the dimensions with written weights, see [below](#ready-for-real-work-mainstream-practitioner-and-essentials-user) | | **Mainstream practitioner** | **~51%** (range 46–56%) | estimated: weekly areas of a working photographer, depth 74.0% × 0.804 × colour 0.85 | @@ -47,7 +47,7 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Breadth | Ready for real work | Remaining | Doc | |---|---:|---:|---:|---| | Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | -| ↳ RAW cameras, colour and lens profiles (part of features) | 82% of Adobe's 1,446 models decode (36% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | +| ↳ RAW cameras, colour and lens profiles (part of features) | 83% of Adobe's 1,446 models decode (38% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | | UI/UX fidelity | ~85% | ~70% | 32–54 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | @@ -259,7 +259,7 @@ verification. Calibrated on this repository's history (git log and GitHub PR ope (#279), NEF lossy after split 4.7 h (#633), ORF 12.8 bpp 1.7 h (#651), HEIC 7 h (#609). - **Large features:** AI RAW denoise 8.3 h (#384, 12.8k lines), SAM 3 masks 14.9 h (#162), HDR editing and export 2.3 h (#646, on existing groundwork), contact-sheet PDF (#563), German UI - 13.8 h (#265). + 13.8 h (#267). - PR open time includes queueing and review, so these are upper bounds per feature; the ranges add a long-tail factor of 1.3–1.5× for verification against real files. @@ -304,6 +304,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | | 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | +| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277); headline numbers unchanged | | 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) | | 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (raw-parity.md): use-weighted share whose camera decodes 85% → 86%, RAW decoding 77% (unchanged); mainstream depth 72.1% → 72.4%, essentials depth 77.5% → 77.6%; headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index f7d54dda1..92edb8d6b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -121,6 +121,30 @@ - Raws from the Nikon Z 6II, Z 8 and D5300 now start from a colour profile built from a few hundred photos from each camera, the same way the Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo shows only a little of come out closer to the camera's JPEG. + +- A command given photos it can't read as photo ids (`"id": "5"`, or a list with something else in it) from a + script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead, + or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos. + +### An empty library leads to Import +- A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a + Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app, + Import Lightroom Catalog… as buttons, and say that photos (and, in the desktop app, folders) can be dropped into + the window. The shortcut they show is the one Import Photos… has now. +- The top bar has an Import button, beside Export, in every library. + +### Reliability + +### One kind of button +- Dialogs' buttons and About's Discord link are drawn by one button component: primary (filled with the accent), + secondary, danger (filled red) and ghost, in three sizes, with an icon or without, and an icon alone (named for + screen readers). Each darkens a little when hovered and more when pressed, shows a ring where the keyboard is, and + its text reads at 4.5:1 or more on its fill in every theme. Dialog buttons are 30 points tall, like the welcome's. + +### A dialog's confirm button says it's the next step +- In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or + brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive + choice is never the eye-catching default. ### Closer camera colour for lens-corrected raws - Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index b4cb6a02b..c467a471a 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -147,7 +147,18 @@ Values pass through our control specs, so anything outside our slider ranges get `crs:CameraProfile` is kept as the selected name in the photo's import warning and the active profile is left as it is: the name carries no rendering data and never selects an Adobe profile. Import reports such a profile as not applied (colours may differ from Lightroom), except Lightroom's default profiles (`Adobe Standard`, `Adobe Color`), which every sidecar names and which LightCraft's own starting look stands in for. -**Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur, +**Profiles named by presets (`Look`, issue #1001).** A preset keeps the profile it names (`crs:Look`: `Name`, `Group`, +`UUID`, `Amount`; in `.lrtemplate` files the `Look` table) as `profile.look` in its settings. When the user has imported +a profile of that name (`profile.import`; the name compared ignoring case and repeated spaces, the group breaking +ties), applying the preset selects it at the look's amount (`Amount` 1 = 100 %). Lightroom's basic profiles map to our +own (`Adobe Color` → Color, `Adobe Neutral`, `Adobe Vivid`, `Adobe Landscape`, `Adobe Portrait`, `Adobe Monochrome` +→ Monochrome). Any other `Adobe …` / `Camera …` profile and the numbered creative ones (`Artistic 01`, `B&W 05`, +`Modern 10`, `Vintage 02`) can't be used here, and a third-party profile not imported yet leaves the photo's profile as it +is; import reports either per preset (`Profile “Name” (Lightroom's own)` / `(not imported yet)` in `unmapped`). +References are resolved again whenever profiles are imported or deleted, so a pack's profiles and presets can come in +either order. The look's embedded `Parameters` are not read. + +**Not mapped:** camera profiles (`CameraProfile`; we have our own profile set), red eye, lens blur, process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below. ## Spot removal @@ -230,7 +241,7 @@ mask's kind: the next brush / linear / radial / … mask starts with it (`mask.t |---|---| | Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. | | Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. | -| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | +| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, a profile named by `Look` that isn't imported, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | | XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. | LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`). diff --git a/xtask/src/ci_report.rs b/xtask/src/ci_report.rs new file mode 100644 index 000000000..dfbd9f5f9 --- /dev/null +++ b/xtask/src/ci_report.rs @@ -0,0 +1,599 @@ +//! What a `cargo xtask ci` run covered on this machine: the capability probe (OS, CPUs, RAM, GPU +//! adapters per backend, corpus, craft-fonts), each step's result and time, and the tests that +//! skipped for want of hardware or data. Written to `target/ci/report.json` and +//! `target/ci/summary.md` (a block to paste into a PR) and printed after the CI summary. +//! +//! Tests record skips with `lightcraft_testkit::skip!`; each test step points +//! `LIGHTCRAFT_SKIP_LOG` at `target/ci/skips-.jsonl`. The GPU adapters come from the ignored +//! `gpu_probe` test of `lightcraft-gpu` (xtask has no wgpu). Nothing here is sent anywhere. +//! +//! The report holds no host name, user name or absolute path: the workspace root and the home +//! directory are cut from skip reasons. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{Value, json}; + +/// Bumped when a field changes meaning. +pub const SCHEMA: u64 = 1; + +/// The environment variable `lightcraft-testkit` appends skips to. +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// `target/ci` under the workspace root (not `CARGO_TARGET_DIR`: one place to look, like +/// `target/bench`). +pub fn dir(root: &Path) -> PathBuf { + root.join("target").join("ci") +} + +/// Create `target/ci` and remove the previous run's skip logs, report and summary. +pub fn prepare(root: &Path) -> Result { + let dir = dir(root); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if (name.starts_with("skips-") && name.ends_with(".jsonl")) || name == "report.json" || name == "summary.md" { + let _ = std::fs::remove_file(entry.path()); + } + } + Ok(dir) +} + +/// The skip log of one step. +pub fn skip_log(root: &Path, step: &str) -> PathBuf { + let safe: String = step.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect(); + dir(root).join(format!("skips-{safe}.jsonl")) +} + +fn output(program: &str, args: &[&str], cwd: Option<&Path>) -> Option { + let mut c = Command::new(program); + c.args(args); + if let Some(cwd) = cwd { + c.current_dir(cwd); + } + let out = c.output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// Commit, branch and whether the work tree has uncommitted changes. +pub fn git(root: &Path) -> Value { + let commit = output("git", &["rev-parse", "HEAD"], Some(root)); + let branch = output("git", &["rev-parse", "--abbrev-ref", "HEAD"], Some(root)); + let dirty = output("git", &["status", "--porcelain"], Some(root)).map(|s| !s.is_empty()); + json!({"commit": commit, "branch": branch, "dirty": dirty}) +} + +/// OS, version, architecture, CPUs and RAM. +pub fn machine(ram_total_gb: Option, ram_available_mb: Option) -> Value { + json!({ + "os": std::env::consts::OS, + "os_version": os_version(), + "arch": std::env::consts::ARCH, + "cpus": std::thread::available_parallelism().map(|n| n.get()).ok(), + "ram_total_gb": ram_total_gb, + "ram_available_mb": ram_available_mb, + }) +} + +fn os_version() -> Option { + if cfg!(target_os = "linux") { + let release = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|s| s.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=")).map(|v| v.trim_matches('"').to_owned())); + let kernel = output("uname", &["-r"], None); + match (release, kernel) { + (Some(r), Some(k)) => Some(format!("{r}, kernel {k}")), + (r, k) => r.or(k), + } + } else if cfg!(target_os = "macos") { + output("sw_vers", &["-productVersion"], None).map(|v| format!("macOS {v}")) + } else if cfg!(windows) { + output("cmd", &["/C", "ver"], None) + } else { + output("uname", &["-sr"], None) + } +} + +/// `rustc -V` of the toolchain cargo uses. +pub fn toolchain() -> Option { + output(&std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()), &["-V"], None) +} + +/// Test data present: how many of the pinned raw samples, other corpus files, craft-fonts. +pub fn data(root: &Path, pinned_raw: &[&str]) -> Value { + let corpus = std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| root.join("corpus")); + let count = |sub: &str| -> usize { std::fs::read_dir(corpus.join(sub)).map(|d| d.flatten().filter(|e| e.path().is_file()).count()).unwrap_or(0) }; + let pinned = pinned_raw.iter().filter(|name| corpus.join("raw").join(name).is_file()).count(); + let fonts = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from); + json!({ + "corpus": { + "custom_dir": std::env::var_os("LIGHTCRAFT_CORPUS").is_some(), + "raw_pinned": pinned, + "raw_pinned_total": pinned_raw.len(), + "raw_files": count("raw"), + "images": count("images"), + "pngsuite": count("pngsuite"), + }, + "craft_fonts": { + "set": fonts.is_some(), + // crates/engine/build.rs embeds the fonts of `fonts/manifest.txt` (a relative dir may mean the root) + "found": fonts.as_deref().is_some_and(|d| [d.to_path_buf(), root.join(d)].iter().any(|d| d.join("fonts/manifest.txt").is_file())), + }, + }) +} + +/// Backends wgpu can probe, in the order reports list them. +const BACKENDS: [&str; 4] = ["vulkan", "metal", "dx12", "gl"]; + +/// Run `gpu_probe` (the pipeline's device), then once per backend: the backends the pipeline may +/// use, or with `all` every backend in the build (`--hardware`; on Windows that loads the Vulkan +/// driver, which the app avoids by default, issue #136). +pub fn gpu(mut probe: impl FnMut(Option<&str>) -> Result, all: bool) -> Value { + let pipeline = match probe(None).and_then(|s| first_json(&s)) { + Ok(v) => v.get("pipeline").cloned().unwrap_or(Value::Null), + Err(e) => return json!({"error": e}), + }; + let names = |key: &str| -> Vec { + pipeline[key].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default() + }; + let (compiled, used) = (names("compiled"), names("backends")); + let mut backends = Vec::new(); + for b in BACKENDS.iter().filter(|b| compiled.iter().any(|c| c == *b) && (all || used.iter().any(|u| u == *b))) { + backends.push(match probe(Some(b)).and_then(|s| first_json(&s)) { + Ok(v) => v, + Err(e) => json!({"backend": b, "adapters": [], "error": e}), + }); + } + json!({"pipeline": pipeline, "backends": backends}) +} + +/// The JSON of the `gpu_probe: {…}` line in the probe's output (libtest prints around it). +fn first_json(s: &str) -> Result { + let line = s.lines().find_map(|l| l.trim().strip_prefix("gpu_probe: ")).ok_or_else(|| format!("gpu_probe printed no result: {s:?}"))?; + serde_json::from_str(line).map_err(|e| format!("gpu_probe: bad JSON ({e}): {line}")) +} + +/// Backends with at least one hardware (not software) adapter: `--hardware` runs the GPU tests +/// on each. +pub fn hardware_backends(gpu: &Value) -> Vec<(String, String)> { + let mut out = Vec::new(); + for b in gpu["backends"].as_array().into_iter().flatten() { + let Some(name) = b["backend"].as_str() else { continue }; + let hw = b["adapters"].as_array().into_iter().flatten().find(|a| a["type"].as_str().is_some_and(|t| t != "Cpu")); + if let Some(a) = hw { + out.push((name.to_owned(), a["name"].as_str().unwrap_or("?").to_owned())); + } + } + out +} + +/// One test that skipped, possibly in several steps. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Skip { + pub need: String, + pub reason: String, + pub test: String, + pub binary: String, + pub location: String, + pub steps: Vec, +} + +/// The skips of every `skips-.jsonl` in `dir`, deduplicated (the HEIF step reruns some test +/// binaries), sorted by need, then reason. Unreadable lines are dropped. +pub fn read_skips(dir: &Path, root: &Path) -> Vec { + let mut by_key: BTreeMap<(String, String, String, String, String), Vec> = BTreeMap::new(); + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut files: Vec = entries.flatten().map(|e| e.path()).collect(); + files.sort(); + for path in files { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let Some(step) = name.strip_prefix("skips-").and_then(|n| n.strip_suffix(".jsonl")) else { continue }; + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + for line in text.lines() { + let Ok(v) = serde_json::from_str::(line) else { continue }; + let field = |k: &str| v[k].as_str().unwrap_or("").to_owned(); + let key = (field("need"), sanitize(&field("reason"), root), field("test"), field("binary"), field("location")); + let steps = by_key.entry(key).or_default(); + if !steps.iter().any(|s| s == step) { + steps.push(step.to_owned()); + } + } + } + by_key.into_iter().map(|((need, reason, test, binary, location), steps)| Skip { need, reason, test, binary, location, steps }).collect() +} + +/// Cut the workspace root and the home directory from `s`, and fold `dir/../` in paths. +pub fn sanitize(s: &str, root: &Path) -> String { + let mut out = s.to_owned(); + let root = root.display().to_string(); + if !root.is_empty() { + for sep in ['/', '\\'] { + out = out.replace(&format!("{root}{sep}"), ""); + } + out = out.replace(&root, "."); + } + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(|h| h.to_string_lossy().into_owned()); + if let Some(home) = home.filter(|h| h.len() > 1) { + out = out.replace(&home, "~"); + } + out.split(' ').map(fold_parent_dirs).collect::>().join(" ") +} + +/// `crates/raw/../../corpus/raw` → `corpus/raw` (lexically; leading `..` stay). +fn fold_parent_dirs(token: &str) -> String { + if !token.contains("/../") && !token.contains("\\..\\") { + return token.to_owned(); + } + let sep = if token.contains("/../") { '/' } else { '\\' }; + let mut parts: Vec<&str> = Vec::new(); + for part in token.split(sep) { + if part == ".." && parts.last().is_some_and(|p| *p != ".." && !p.is_empty()) { + parts.pop(); + } else { + parts.push(part); + } + } + parts.join(&sep.to_string()) +} + +pub fn skips_json(skips: &[Skip]) -> Value { + Value::Array( + skips + .iter() + .map(|s| json!({"need": s.need, "reason": s.reason, "test": s.test, "binary": s.binary, "location": s.location, "steps": s.steps})) + .collect(), + ) +} + +/// What an empty skip list means: no test skipped, or the tests never ran. +fn no_skips(report: &Value) -> &'static str { + let test_ran = report["steps"].as_array().into_iter().flatten().any(|s| s["name"] == "test" && s["status"] != "not run"); + if test_ran { "none recorded" } else { "unknown, the test step did not run" } +} + +fn skips_from(report: &Value) -> Vec { + report["skips"] + .as_array() + .into_iter() + .flatten() + .map(|v| { + let f = |k: &str| v[k].as_str().unwrap_or("").to_owned(); + let steps = v["steps"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_default(); + Skip { need: f("need"), reason: f("reason"), test: f("test"), binary: f("binary"), location: f("location"), steps } + }) + .collect() +} + +/// Skip counts per need, most first: `[("corpus", 40), ("gpu", 5)]`. +pub fn counts_by_need(skips: &[Skip]) -> Vec<(String, usize)> { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for s in skips { + *counts.entry(&s.need).or_default() += 1; + } + let mut v: Vec<(String, usize)> = counts.into_iter().map(|(k, n)| (k.to_owned(), n)).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v +} + +/// What gets the skipped tests of `need` running. +fn hint(need: &str) -> &'static str { + match need { + "gpu" => "a hardware GPU adapter the pipeline accepts", + "corpus" => "the corpus (`cargo xtask corpus --download`; some files are private)", + "fonts" => "craft-fonts (`CRAFT_FONTS_DIR`, see the README)", + "model" => "the model files the reasons name", + "input" => "the input files the reasons name", + "system" => "what the operating system provides", + "feature" => "support this build doesn't have yet", + _ => "what the reasons name", + } +} + +/// The skips of `need`, grouped by reason, largest group first. +fn by_reason<'a>(skips: &'a [Skip], need: &str) -> Vec<(&'a str, Vec<&'a Skip>)> { + let mut groups: BTreeMap<&str, Vec<&Skip>> = BTreeMap::new(); + for k in skips.iter().filter(|k| k.need == need) { + groups.entry(&k.reason).or_default().push(k); + } + let mut groups: Vec<(&str, Vec<&Skip>)> = groups.into_iter().collect(); + groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0))); + groups +} + +pub fn seconds(s: f64) -> String { + if s < 60.0 { format!("{s:.1} s") } else { format!("{}m {:02}s", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) } +} + +fn test_name(s: &Skip) -> String { + if s.test.is_empty() || s.test == "main" { format!("{} ({})", s.binary, s.location) } else { format!("{}::{}", s.binary, s.test) } +} + +fn adapter_line(a: &Value) -> String { + let name = a["name"].as_str().unwrap_or("?"); + let kind = a["type"].as_str().unwrap_or("?"); + let driver = [a["driver"].as_str().unwrap_or(""), a["driver_info"].as_str().unwrap_or("")] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(" "); + if driver.is_empty() { format!("{name} ({kind})") } else { format!("{name} ({kind}; {driver})") } +} + +/// "the pipeline's device" line. +fn gpu_line(report: &Value) -> String { + let gpu = &report["capabilities"]["gpu"]; + if let Some(e) = gpu["error"].as_str() { + return format!("probe failed: {e}"); + } + let p = &gpu["pipeline"]; + match (p["available"].as_bool(), p["adapter"].as_str(), p["reason"].as_str()) { + (Some(true), Some(a), _) => a.to_owned(), + (_, _, Some(r)) => format!("none ({r})"), + _ => "not probed".to_owned(), + } +} + +fn adapters_lines(report: &Value) -> Vec { + let mut out = Vec::new(); + for b in report["capabilities"]["gpu"]["backends"].as_array().into_iter().flatten() { + let name = b["backend"].as_str().unwrap_or("?"); + let list: Vec = b["adapters"].as_array().into_iter().flatten().map(adapter_line).collect(); + let text = match (b["error"].as_str(), list.is_empty()) { + (Some(e), _) => format!("probe failed: {e}"), + (None, true) => "no adapter".to_owned(), + (None, false) => list.join(", "), + }; + out.push(format!("{name}: {text}")); + } + out +} + +fn machine_line(report: &Value) -> String { + let m = &report["machine"]; + let mut s = format!("{} {}", m["os"].as_str().unwrap_or("?"), m["arch"].as_str().unwrap_or("?")); + if let Some(v) = m["os_version"].as_str() { + let _ = write!(s, " ({v})"); + } + if let Some(n) = m["cpus"].as_u64() { + let _ = write!(s, ", {n} CPUs"); + } + if let Some(gb) = m["ram_total_gb"].as_u64() { + let _ = write!(s, ", {gb} GB RAM"); + } + if let Some(mb) = m["ram_available_mb"].as_u64() { + let _ = write!(s, " ({:.1} GB available)", mb as f64 / 1024.0); + } + s +} + +fn data_line(report: &Value) -> String { + let d = &report["capabilities"]["data"]; + let c = &d["corpus"]; + let n = |k: &str| c[k].as_u64().unwrap_or(0); + let fonts = match (d["craft_fonts"]["set"].as_bool(), d["craft_fonts"]["found"].as_bool()) { + (Some(true), Some(true)) => "CRAFT_FONTS_DIR set", + (Some(true), _) => "CRAFT_FONTS_DIR set but not found", + _ => "no CRAFT_FONTS_DIR", + }; + format!("corpus: {} of {} pinned raws, {} images, {} PngSuite files; {fonts}", n("raw_pinned"), n("raw_pinned_total"), n("images"), n("pngsuite")) +} + +fn commit_line(report: &Value) -> String { + let g = &report["git"]; + let commit = g["commit"].as_str().map_or("unknown commit".to_owned(), |c| c.chars().take(10).collect()); + let state = match g["dirty"].as_bool() { + Some(true) => "with uncommitted changes", + Some(false) => "clean", + None => "state unknown", + }; + format!("{commit} ({state})") +} + +/// The lines printed after the CI summary. +pub fn summary_text(report: &Value) -> String { + let mut s = String::new(); + let _ = writeln!(s, "\nMachine: {}", machine_line(report)); + let _ = writeln!(s, "GPU for the pipeline: {}", gpu_line(report)); + for line in adapters_lines(report) { + let _ = writeln!(s, " adapters on {line}"); + } + let _ = writeln!(s, "Data: {}", data_line(report)); + for h in report["hardware"].as_array().into_iter().flatten() { + let _ = writeln!( + s, + "GPU tests on {} ({}): {}", + h["backend"].as_str().unwrap_or("?"), + h["adapter"].as_str().unwrap_or("?"), + h["status"].as_str().unwrap_or("?") + ); + } + let skips = skips_from(report); + if skips.is_empty() { + let _ = writeln!(s, "Skipped tests: {}", no_skips(report)); + } else { + let counts: Vec = counts_by_need(&skips).iter().map(|(k, n)| format!("{k} {n}")).collect(); + let _ = writeln!(s, "Skipped tests: {} passed without running their checks ({}):", skips.len(), counts.join(", ")); + for (need, n) in counts_by_need(&skips) { + let _ = writeln!(s, " {need} ({n}): needs {}", hint(&need)); + let groups = by_reason(&skips, &need); + const SHOWN: usize = 4; + for (reason, tests) in groups.iter().take(SHOWN) { + let first = tests.first().map(|t| test_name(t)).unwrap_or_default(); + let more = if tests.len() > 1 { ", …" } else { "" }; + let _ = writeln!(s, " {:>3}× {reason} ({first}{more})", tests.len()); + } + if groups.len() > SHOWN { + let rest: usize = groups.iter().skip(SHOWN).map(|(_, t)| t.len()).sum(); + let _ = writeln!(s, " {rest:>3}× {} other reasons (target/ci/report.json)", groups.len() - SHOWN); + } + } + } + s +} + +fn cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + +/// A Markdown block for a PR description or comment. +pub fn summary_md(report: &Value) -> String { + let mut s = String::new(); + let result = report["result"].as_str().unwrap_or("?"); + let command = report["command"].as_str().unwrap_or("cargo xtask ci"); + let _ = writeln!(s, ""); + let _ = writeln!(s, "**`{command}`: {result}** at {}\n", commit_line(report)); + let _ = writeln!(s, "| | |\n|---|---|"); + let _ = writeln!(s, "| Machine | {} |", cell(&machine_line(report))); + if let Some(t) = report["toolchain"].as_str() { + let _ = writeln!(s, "| Rust | {} |", cell(t)); + } + let _ = writeln!(s, "| GPU for the pipeline | {} |", cell(&gpu_line(report))); + let adapters = adapters_lines(report); + if !adapters.is_empty() { + let _ = writeln!(s, "| Adapters | {} |", cell(&adapters.join("; "))); + } + let _ = writeln!(s, "| Data | {} |", cell(&data_line(report))); + let _ = writeln!(s, "\n| Step | Result | Time |\n|---|---|---|"); + for step in report["steps"].as_array().into_iter().flatten() { + let time = step["seconds"].as_f64().map(seconds).unwrap_or_default(); + let _ = writeln!(s, "| {} | {} | {time} |", cell(step["name"].as_str().unwrap_or("?")), step["status"].as_str().unwrap_or("?")); + } + for h in report["hardware"].as_array().into_iter().flatten() { + let time = h["seconds"].as_f64().map(seconds).unwrap_or_default(); + let name = format!("GPU tests on {} ({})", h["backend"].as_str().unwrap_or("?"), h["adapter"].as_str().unwrap_or("?")); + let _ = writeln!(s, "| {} | {} | {time} |", cell(&name), h["status"].as_str().unwrap_or("?")); + } + let skips = skips_from(report); + if skips.is_empty() { + let _ = writeln!(s, "\nSkipped tests: {}.", no_skips(report)); + } else { + let counts: Vec = counts_by_need(&skips).iter().map(|(k, n)| format!("{k} {n}")).collect(); + let _ = writeln!(s, "\n
{} tests skipped ({})\n", skips.len(), counts.join(", ")); + let _ = writeln!(s, "| Need | Tests | Reason | For example |\n|---|---|---|---|"); + for (need, _) in counts_by_need(&skips) { + let groups = by_reason(&skips, &need); + const SHOWN: usize = 12; + for (reason, tests) in groups.iter().take(SHOWN) { + let first = tests.first().map(|t| test_name(t)).unwrap_or_default(); + let _ = writeln!(s, "| {} | {} | {} | `{}` |", cell(&need), tests.len(), cell(reason), cell(&first)); + } + if groups.len() > SHOWN { + let rest: usize = groups.iter().skip(SHOWN).map(|(_, t)| t.len()).sum(); + let _ = writeln!(s, "| {} | {rest} | {} other reasons | |", cell(&need), groups.len() - SHOWN); + } + } + let _ = writeln!(s, "\n
"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("xtask-ci-report-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn skips_are_read_per_step_deduplicated_and_cut_free_of_the_root() { + let root = Path::new("/work/lightcraft"); + let dir = tmp("skips"); + let gpu = + r#"{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}"#; + let corpus = r#"{"need":"corpus","reason":"skip /work/lightcraft/crates/raw/../../corpus/raw absent","test":"decodes","binary":"corpus","location":"crates/raw/tests/corpus.rs:28"}"#; + std::fs::write(dir.join("skips-heif.jsonl"), format!("{corpus}\n")).unwrap(); + std::fs::write(dir.join("skips-test.jsonl"), format!("{gpu}\n{corpus}\nnot json\n")).unwrap(); + std::fs::write(dir.join("other.jsonl"), format!("{gpu}\n")).unwrap(); + let skips = read_skips(&dir, root); + assert_eq!(skips.len(), 2, "{skips:?}"); + assert_eq!(skips[0].need, "corpus"); + assert_eq!(skips[0].reason, "skip corpus/raw absent"); + assert_eq!(skips[0].steps, ["heif", "test"]); + assert_eq!(skips[1].steps, ["test"]); + assert_eq!(counts_by_need(&skips), [("corpus".to_owned(), 1), ("gpu".to_owned(), 1)]); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn prepare_clears_the_last_runs_files_only() { + let root = tmp("prepare"); + let dir = dir(&root); + std::fs::create_dir_all(&dir).unwrap(); + for f in ["skips-test.jsonl", "report.json", "summary.md", "keep.txt"] { + std::fs::write(dir.join(f), "x").unwrap(); + } + prepare(&root).unwrap(); + let left: Vec = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(left, ["keep.txt"]); + assert_eq!(skip_log(&root, "gpu:dx12").file_name().unwrap(), "skips-gpu-dx12.jsonl"); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn parent_dirs_fold_in_path_tokens_only() { + assert_eq!(fold_parent_dirs("crates/raw/../../corpus/raw"), "corpus/raw"); + assert_eq!(fold_parent_dirs("../corpus/raw"), "../corpus/raw"); + assert_eq!(fold_parent_dirs("C:\\w\\crates\\raw\\..\\..\\corpus"), "C:\\w\\corpus"); + assert_eq!(fold_parent_dirs("absent"), "absent"); + } + + #[test] + fn the_probe_asks_the_pipelines_backends_or_all_with_hardware() { + let pipeline = r#"{"pipeline":{"available":false,"adapter":null,"reason":"software adapter (llvmpipe) skipped","backends":["vulkan","dx12"],"compiled":["vulkan","dx12","gl"]}}"#; + let asked = std::cell::RefCell::new(Vec::new()); + let probe = |b: Option<&str>| -> Result { + asked.borrow_mut().push(b.map(str::to_owned)); + Ok(match b { + None => format!("running 1 test\ntest gpu_probe ... \ngpu_probe: {pipeline}\nok\n"), + Some("vulkan") => r#"gpu_probe: {"backend":"vulkan","adapters":[{"name":"llvmpipe","type":"Cpu","driver":"llvmpipe","driver_info":"Mesa"}]}"#.to_owned(), + Some("dx12") => r#"gpu_probe: {"backend":"dx12","adapters":[{"name":"Intel(R) UHD Graphics 620","type":"IntegratedGpu","driver":"","driver_info":"31.0.101"}]}"#.to_owned(), + Some(_) => return Err("crashed".into()), + }) + }; + let gpu_default = gpu(probe, false); + assert_eq!(*asked.borrow(), [None, Some("vulkan".to_owned()), Some("dx12".to_owned())]); + assert_eq!(hardware_backends(&gpu_default), [("dx12".to_owned(), "Intel(R) UHD Graphics 620".to_owned())]); + asked.borrow_mut().clear(); + let gpu_all = gpu(probe, true); + assert_eq!(asked.borrow().len(), 4, "gl too"); + assert_eq!(gpu_all["backends"][2]["error"], "crashed"); + + let report = json!({ + "command": "cargo xtask ci", + "result": "pass", + "git": {"commit": "0123456789abcdef", "dirty": false}, + "machine": {"os": "windows", "arch": "x86_64", "cpus": 8, "ram_total_gb": 16}, + "capabilities": {"gpu": gpu_all, "data": {"corpus": {"raw_pinned": 3, "raw_pinned_total": 30}, "craft_fonts": {"set": false}}}, + "steps": [{"name": "fmt", "status": "ok", "seconds": 1.25}, {"name": "test", "status": "ok", "seconds": 125.0}], + "skips": [{"need": "gpu", "reason": "no GPU | adapter", "test": "memory", "binary": "memory", "location": "x.rs:1", "steps": ["test"]}], + }); + let text = summary_text(&report); + assert!(text.contains("GPU for the pipeline: none (software adapter (llvmpipe) skipped)"), "{text}"); + assert!(text.contains("adapters on dx12: Intel(R) UHD Graphics 620 (IntegratedGpu; 31.0.101)"), "{text}"); + assert!(text.contains("adapters on gl: probe failed: crashed"), "{text}"); + assert!(text.contains("Skipped tests: 1 passed without running their checks (gpu 1)"), "{text}"); + assert!(text.contains(" gpu (1): needs a hardware GPU adapter"), "{text}"); + assert!(text.contains(" 1× no GPU | adapter (memory::memory)"), "{text}"); + assert!(text.contains("corpus: 3 of 30 pinned raws"), "{text}"); + let md = summary_md(&report); + assert!(md.contains("**`cargo xtask ci`: pass** at 0123456789 (clean)"), "{md}"); + assert!(md.contains("| test | ok | 2m 05s |"), "{md}"); + assert!(md.contains("| gpu | 1 | no GPU \\| adapter | `memory::memory` |"), "{md}"); + assert!(md.contains("1 tests skipped (gpu 1)"), "{md}"); + } + + #[test] + fn no_skips_is_not_claimed_when_the_tests_never_ran() { + let stopped = json!({"steps": [{"name": "clippy", "status": "fail"}, {"name": "test", "status": "not run"}], "skips": []}); + assert!(summary_text(&stopped).contains("Skipped tests: unknown, the test step did not run")); + let ran = json!({"steps": [{"name": "test", "status": "ok"}], "skips": []}); + assert!(summary_md(&ran).contains("Skipped tests: none recorded.")); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 434c36138..8a7d597b5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ mod assets; mod bench; +mod ci_report; mod ico; mod layers; mod parity; @@ -33,7 +34,11 @@ commands: web [--serve [port]] [--dev] build the browser app (apps/lightcraft-web) into /web-site/; --serve serves it on http://127.0.0.1: (default 8080) - ci fmt --check, clippy -D warnings, heif, test, parity refs, layers, assets, wasm (stops at first failure) + ci [--hardware] [--summary-md] + fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm + (stops at first failure); writes target/ci/report.json (machine, GPU adapters, data, + steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on + every backend with a hardware adapter; --summary-md prints the Markdown summary corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256 stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) @@ -56,7 +61,7 @@ fn main() -> ExitCode { Some("parity") => parity::run(&root(), rest.contains(&"--write")), Some("wasm") => cmd_wasm(), Some("web") => web::run(&rest), - Some("ci") => cmd_ci(), + Some("ci") => cmd_ci(&rest), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("raw-frame") => cmd_raw_frame(&rest), @@ -260,9 +265,38 @@ fn cmd_wasm() -> Result<(), String> { if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } } -fn cmd_ci() -> Result<(), String> { - type Step = (&'static str, Box Result<(), String>>); - let steps: Vec = vec![ +fn cmd_ci(args: &[&str]) -> Result<(), String> { + let hardware = args.contains(&"--hardware"); + let print_md = args.contains(&"--summary-md"); + if let Some(other) = args.iter().find(|a| !matches!(**a, "--hardware" | "--summary-md")) { + return Err(format!("ci: unknown option `{other}`\n\n{USAGE}")); + } + let root = root(); + let report_dir = ci_report::prepare(&root)?; + let started = std::time::Instant::now(); + let started_unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + // test runs append the skips their tests record (`lightcraft_testkit::skip!`) to target/ci/skips-.jsonl + let test = |cargo_args: &[&str], step: &str| { + let mut c = cargo(); + c.args(cargo_args).env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, step)); + run(c, &format!("cargo {}", cargo_args.join(" "))) + }; + let gpu = std::cell::RefCell::new(serde_json::Value::Null); + let hardware_runs = std::cell::RefCell::new(Vec::new()); + // What this machine offers the GPU tests, once, after the workspace tests (whose build the + // probe reuses). Never fails the run: the report says what went wrong. + let probe = || { + if gpu.borrow().is_null() { + eprintln!("\n=== ci: GPU probe ==="); + let t = std::time::Instant::now(); + let mut found = ci_report::gpu(probe_gpu, hardware); + found["seconds"] = serde_json::json!(t.elapsed().as_secs_f64()); + *gpu.borrow_mut() = found; + } + }; + + type Step<'a> = (&'static str, Box Result<(), String> + 'a>); + let mut steps: Vec = vec![ ( "fmt", Box::new(|| { @@ -286,46 +320,144 @@ fn cmd_ci() -> Result<(), String> { let mut c = cargo(); c.args(["clippy", "-p", "lightcraft-codecs", "--features", "heif", "--all-targets", "--", "-D", "warnings"]); run(c, "cargo clippy -p lightcraft-codecs --features heif --all-targets -- -D warnings")?; - let mut c = cargo(); - c.args(["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"]); - run(c, "cargo test -p lightcraft-codecs -p lightcraft-heif --features lightcraft-codecs/heif")?; + test(&["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"], "heif")?; // the engine's HEIC import test, with the decoder (the workspace run checks the error) - let mut c = cargo(); - c.args(["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"]); - run(c, "cargo test -p lightcraft-engine --lib --features lightcraft-codecs/heif heic") + test(&["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"], "heif") }), ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") - }), - ), - ("parity", Box::new(|| parity::run(&root(), false))), - ("layers", Box::new(cmd_layers)), - ("assets", Box::new(|| assets::run(&root()))), - ("wasm", Box::new(cmd_wasm)), + ("test", Box::new(|| test(&["test", "--workspace"], "test"))), ]; - let mut done = Vec::new(); + if hardware { + steps.push(( + "gpu-backends", + Box::new(|| { + // the GPU tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`) + probe(); + let backends = ci_report::hardware_backends(&gpu.borrow()); + if backends.is_empty() { + eprintln!("no hardware GPU adapter on any backend: nothing to run"); + } + let mut failed = Vec::new(); + for (backend, adapter) in backends { + let step = format!("gpu-{backend}"); + let mut c = cargo(); + c.args(["test", "-p", "lightcraft-gpu", "--features", "denoise"]) + .env("LIGHTCRAFT_GPU_BACKEND", &backend) + .env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, &step)); + let t = std::time::Instant::now(); + let ok = run(c, &format!("LIGHTCRAFT_GPU_BACKEND={backend} cargo test -p lightcraft-gpu --features denoise")).is_ok(); + hardware_runs.borrow_mut().push(serde_json::json!({ + "backend": backend, + "adapter": adapter, + "status": if ok { "ok" } else { "fail" }, + "seconds": t.elapsed().as_secs_f64(), + })); + if !ok { + failed.push(backend); + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("the GPU tests failed on {}", failed.join(", "))) } + }), + )); + } + steps.push(("parity", Box::new(|| parity::run(&root, false)))); + steps.push(("layers", Box::new(cmd_layers))); + steps.push(("assets", Box::new(|| assets::run(&root)))); + steps.push(("wasm", Box::new(cmd_wasm))); + + let mut results = Vec::new(); + let mut failure = None; for (name, f) in &steps { eprintln!("\n=== ci: {name} ==="); - if let Err(e) = f() { + let t = std::time::Instant::now(); + let r = f(); + let status = if r.is_ok() { "ok" } else { "fail" }; + results.push(serde_json::json!({"name": name, "status": status, "seconds": t.elapsed().as_secs_f64()})); + if *name == "test" { + // right after the workspace tests, before parity and wasm build other feature sets + probe(); + } + if let Err(e) = r { + failure = Some((*name, e)); + break; + } + } + if gpu.borrow().is_null() { + *gpu.borrow_mut() = serde_json::json!({"error": "not probed: the test step did not run"}); + } + let done: Vec<&str> = steps.iter().take(results.len()).map(|(n, _)| *n).collect(); + match &failure { + Some((name, e)) => { println!("\nCI summary:"); - for d in &done { + for d in done.iter().filter(|d| *d != name) { println!(" ok {d}"); } println!(" FAIL {name}: {e}"); - for (n, _) in steps.iter().skip(done.len() + 1) { + for (n, _) in steps.iter().skip(results.len()) { println!(" skip {n}"); } - return Err(format!("ci failed at `{name}`")); } - done.push(*name); + None => println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")), } - println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")); - Ok(()) + for (n, _) in steps.iter().skip(results.len()) { + results.push(serde_json::json!({"name": n, "status": "not run"})); + } + + let skips = ci_report::read_skips(&report_dir, &root); + let ram_mb = available_ram_mb(); + let report = serde_json::json!({ + "schema": ci_report::SCHEMA, + "command": std::iter::once("cargo xtask ci").chain(args.iter().copied()).collect::>().join(" "), + "result": if failure.is_some() { "fail" } else { "pass" }, + "failed_step": failure.as_ref().map(|(n, _)| *n), + "started_unix": started_unix, + "seconds": started.elapsed().as_secs_f64(), + "git": ci_report::git(&root), + "machine": ci_report::machine(total_ram_gb(), ram_mb), + "toolchain": ci_report::toolchain(), + "capabilities": {"gpu": gpu.take(), "data": ci_report::data(&root, &RAW_SAMPLES.iter().map(|s| s.0).collect::>())}, + "steps": results, + "hardware": hardware_runs.take(), + "skips": ci_report::skips_json(&skips), + }); + print!("{}", ci_report::summary_text(&report)); + let md = ci_report::summary_md(&report); + let written = serde_json::to_string_pretty(&report) + .map_err(|e| e.to_string()) + .and_then(|json| std::fs::write(report_dir.join("report.json"), json + "\n").map_err(|e| e.to_string())) + .and_then(|()| std::fs::write(report_dir.join("summary.md"), &md).map_err(|e| e.to_string())); + match written { + Ok(()) => println!("Report: target/ci/report.json; to paste into a PR: target/ci/summary.md (or run with --summary-md)"), + Err(e) => eprintln!("ci: could not write the report to {}: {e}", report_dir.display()), + } + if print_md { + println!("\n{md}"); + } + match failure { + Some((name, _)) => Err(format!("ci failed at `{name}`")), + None => Ok(()), + } +} + +/// Run the ignored `gpu_probe` test of lightcraft-gpu: the pipeline's device (`None`) or one +/// backend's adapters. `--workspace`, like the test step, so its build is reused. Its stdout, or why +/// it failed (a driver that crashes while it is enumerated, issue #136, ends up here, not in xtask). +fn probe_gpu(backend: Option<&str>) -> Result { + let mut c = cargo(); + c.args(["test", "-q", "--workspace", "--test", "gpu_probe", "--", "--ignored", "--exact", "gpu_probe", "--nocapture"]); + c.env("LIGHTCRAFT_GPU_PROBE", backend.unwrap_or("")); + let out = c.output().map_err(|e| format!("gpu_probe: {e}"))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = err.lines().rev().filter(|l| !l.trim().is_empty()).take(3).collect(); + return Err(format!( + "gpu_probe {}exited with {}: {}", + backend.map(|b| format!("{b} ")).unwrap_or_default(), + out.status, + tail.into_iter().rev().collect::>().join(" / ") + )); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// CC0 raw samples (raw.pixls.us). Small, representative set; extend freely (CC0 only). @@ -333,6 +465,207 @@ fn cmd_ci() -> Result<(), String> { /// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it. /// One per format / compression variant we decode or deliberately report as unsupported (preview only). const RAW_SAMPLES: &[(&str, &str, &str)] = &[ + // Camera-coverage sweep: CC0 files, sensor hashes and comparisons in docs/raw/coverage-sweep.tsv. + ( + "coverage-915.dng", + "https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG", + "845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c", + ), + ( + "coverage-939.dng", + "https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG", + "e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e", + ), + ( + "coverage-3372.dng", + "https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng", + "50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6", + ), + ( + "coverage-3009.dng", + "https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG", + "7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec", + ), + ( + "coverage-5140.dng", + "https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG", + "dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4", + ), + ( + "coverage-8041.dng", + "https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG", + "901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0", + ), + ( + "coverage-4785.dng", + "https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG", + "21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851", + ), + ( + "coverage-4898.dng", + "https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG", + "fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d", + ), + ( + "coverage-3502.dng", + "https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng", + "0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54", + ), + ( + "coverage-4176.dng", + "https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng", + "11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be", + ), + ( + "coverage-5961.dng", + "https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng", + "3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d", + ), + ( + "coverage-7751.dng", + "https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng", + "45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5", + ), + ( + "coverage-7217.3fr", + "https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR", + "e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0", + ), + ( + "coverage-7218.fff", + "https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff", + "c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932", + ), + ( + "coverage-1640.fff", + "https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff", + "209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832", + ), + ( + "coverage-1639.fff", + "https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff", + "1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0", + ), + ( + "coverage-2058.3fr", + "https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR", + "395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3", + ), + ( + "coverage-3874.fff", + "https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff", + "8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794", + ), + ( + "coverage-3941.3fr", + "https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR", + "809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47", + ), + ( + "coverage-6568.fff", + "https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff", + "44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc", + ), + ( + "coverage-6565.3fr", + "https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR", + "dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280", + ), + ( + "coverage-4726.nef", + "https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233", + ), + ( + "coverage-4720.nef", + "https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f", + ), + ( + "coverage-4721.nef", + "https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838", + ), + ( + "coverage-3584.nef", + "https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886", + ), + ( + "coverage-3581.nef", + "https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", + "8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02", + ), + ( + "coverage-3582.nef", + "https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF", + "c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31", + ), + ( + "coverage-2792.nef", + "https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded", + ), + ( + "coverage-2789.nef", + "https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e", + ), + ( + "coverage-2790.nef", + "https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114", + ), + ( + "coverage-6617.nef", + "https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), + ( + "coverage-2912.dng", + "https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG", + "a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6", + ), + ( + "coverage-6728.dng", + "https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng", + "5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e", + ), + ( + "coverage-6261.dng", + "https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng", + "53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581", + ), + ( + "coverage-6697.dng", + "https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng", + "1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067", + ), + ( + "coverage-1297.dng", + "https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng", + "843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561", + ), + ( + "coverage-2645.dng", + "https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng", + "6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2", + ), + ( + "coverage-2310.dng", + "https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng", + "3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c", + ), + ( + "coverage-7273.dng", + "https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG", + "0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9", + ), + ( + "coverage-6949.dng", + "https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG", + "8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70", + ), ( "arw-sony-a7rm4a-compressed.arw", "https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW",