diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index c63fdeef54..b10e65a75f 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 path: craft-fonts - name: Build and test in a FreeBSD VM uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000000..d992f74335 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,28 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - name: nix flake check --no-build + run: nix flake check --no-build --all-systems diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 8953710cec..7ec8559928 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,6 +21,7 @@ concurrency: jobs: lint: + timeout-minutes: 15 runs-on: ubuntu-latest env: ACTIONLINT_VERSION: 1.7.12 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7e3036d64..392ee4eb7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,7 @@ env: jobs: version: + timeout-minutes: 10 name: version runs-on: ubuntu-latest # No `environment: release` here or on the other jobs that sign nothing: no release secrets. @@ -65,6 +66,7 @@ jobs: echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" macos: + timeout-minutes: 90 name: macOS universal needs: version runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) @@ -78,7 +80,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: @@ -114,6 +116,7 @@ jobs: if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi windows: + timeout-minutes: 90 name: Windows ${{ matrix.arch }} needs: version runs-on: windows-latest @@ -136,7 +139,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: @@ -173,6 +176,7 @@ jobs: if-no-files-found: error linux: + timeout-minutes: 90 name: Linux ${{ matrix.arch }} needs: version # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, @@ -194,7 +198,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - name: Install build and packaging tools run: | @@ -237,6 +241,7 @@ jobs: if-no-files-found: error flatpak: + timeout-minutes: 90 name: Flatpak ${{ matrix.arch }} needs: [version, linux] # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust @@ -274,6 +279,7 @@ jobs: if-no-files-found: error linux-riscv64: + timeout-minutes: 90 name: Linux riscv64 needs: version # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release @@ -291,7 +297,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: @@ -362,7 +368,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - name: Build and package in a FreeBSD VM (tar.gz) # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). @@ -398,6 +404,7 @@ jobs: if-no-files-found: error web: + timeout-minutes: 90 name: Web (wasm) needs: version runs-on: ubuntu-latest @@ -411,7 +418,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately + ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 # bump deliberately path: craft-fonts - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: @@ -441,6 +448,7 @@ jobs: if-no-files-found: error release: + timeout-minutes: 10 name: Draft GitHub Release needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 8cd199ff63..c9f54b71da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,14 +125,18 @@ 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 and not -in CI: it may lag behind the workspace; see README → Quick start. +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. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index 136e821bbf..00a2314aa9 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 and not -in CI: it may lag behind the workspace; see README → Quick start. +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 bf2ac824f7..4b6d54289f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3073,7 +3073,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.7.1" +version = "0.8.0" dependencies = [ "eframe", "egui", @@ -3081,6 +3081,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-raster", "lightcraft-sysmem", + "lightcraft-testkit", "lightcraft-ui-egui", "log", "muda", @@ -3097,7 +3098,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.7.1" +version = "0.8.0" dependencies = [ "crc32fast", "lightcraft-develop", @@ -3114,7 +3115,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.7.1" +version = "0.8.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3130,7 +3131,7 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.7.1" +version = "0.8.0" dependencies = [ "avif-serialize", "bytemuck", @@ -3144,6 +3145,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-heif", "lightcraft-raster", + "lightcraft-testkit", "moxcms 0.9.1", "png", "proptest", @@ -3159,7 +3161,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3168,7 +3170,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.7.1" +version = "0.8.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3185,7 +3187,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3200,7 +3202,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3212,7 +3214,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.7.1" +version = "0.8.0" dependencies = [ "ab_glyph", "harfrust", @@ -3239,6 +3241,8 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-slideshow", + "lightcraft-testkit", "lightcraft-tiff", "log", "miniz_oxide 0.8.9", @@ -3252,8 +3256,9 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.7.1" +version = "0.8.0" dependencies = [ + "lightcraft-testkit", "matrixmultiply", "ndarray", "proptest", @@ -3266,7 +3271,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.7.1" +version = "0.8.0" dependencies = [ "log", "rustls", @@ -3277,7 +3282,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.7.1" +version = "0.8.0" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3285,7 +3290,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.7.1" +version = "0.8.0" dependencies = [ "proptest", "serde", @@ -3293,17 +3298,19 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.7.1" +version = "0.8.0" dependencies = [ "bytemuck", "lightcraft-color", "lightcraft-denoise", "lightcraft-develop", + "lightcraft-faces", "lightcraft-geom", "lightcraft-pipeline", "lightcraft-raster", "lightcraft-raw", "lightcraft-scenes", + "lightcraft-testkit", "log", "pollster 1.0.1", "wgpu", @@ -3311,7 +3318,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.7.1" +version = "0.8.0" dependencies = [ "heic-rs", "rayon", @@ -3319,14 +3326,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.7.1" +version = "0.8.0" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3335,7 +3342,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3346,7 +3353,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3362,7 +3369,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.7.1" +version = "0.8.0" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3374,7 +3381,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.7.1" +version = "0.8.0" dependencies = [ "image", "lightcraft-color", @@ -3392,7 +3399,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.7.1" +version = "0.8.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3404,7 +3411,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.7.1" +version = "0.8.0" dependencies = [ "libc", "lightcraft-color", @@ -3416,14 +3423,16 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.7.1" +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", @@ -3438,7 +3447,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.7.1" +version = "0.8.0" dependencies = [ "image", "lightcraft-color", @@ -3449,24 +3458,38 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.7.1" +version = "0.8.0" dependencies = [ "candle-core", "candle-nn", "lightcraft-fetch", + "lightcraft-testkit", "log", "serde", "serde_json", "thiserror 2.0.21", ] +[[package]] +name = "lightcraft-slideshow" +version = "0.8.0" +dependencies = [ + "lightcraft-raster", + "serde", + "serde_json", +] + [[package]] name = "lightcraft-sysmem" -version = "0.7.1" +version = "0.8.0" + +[[package]] +name = "lightcraft-testkit" +version = "0.8.0" [[package]] name = "lightcraft-tiff" -version = "0.7.1" +version = "0.8.0" dependencies = [ "proptest", "serde", @@ -3475,7 +3498,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.7.1" +version = "0.8.0" dependencies = [ "craft-ui", "egui", @@ -3493,6 +3516,8 @@ dependencies = [ "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", + "lightcraft-slideshow", + "lightcraft-testkit", "log", "serde", "serde_json", @@ -3502,7 +3527,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.7.1" +version = "0.8.0" dependencies = [ "crc32fast", "eframe", @@ -6972,7 +6997,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.7.1" +version = "0.8.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 7effbe0d94..ac8cbeca13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -39,6 +39,8 @@ lightcraft-fetch = { path = "crates/fetch" } 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" @@ -52,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/NOTICE b/NOTICE index c9d650bb18..8b16eeba0f 100644 --- a/NOTICE +++ b/NOTICE @@ -51,9 +51,10 @@ no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included. Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases) also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md -(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ -UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's -licence as OFL-.txt. +(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans +Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto +Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's +licence as OFL-.txt; Windows installers include their texts in FONT-LICENCES.txt. Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and are never distributed with it. On the user's request (Photo > Download Place Names) the app diff --git a/README.md b/README.md index f041111a1d..53ec908de3 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. @@ -307,11 +321,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det - **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in [docs/parity-checklist.md](docs/parity-checklist.md). -- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for +- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - **AI masks and denoise:** subject and sky selection are classical heuristics; - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); @@ -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 @@ -405,7 +426,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) or **Settings → General → Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md). **Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder diff --git a/README.zh-hans.md b/README.zh-hans.md index 0a7feb32ca..f2d1552147 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**( - **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** diff --git a/ROADMAP.md b/ROADMAP.md index 3f02a11998..f142cd226d 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-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **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,11 +12,11 @@ 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** | **~49%** (44–54%) | estimated | -| **Essentials user** | **~64%** (59–69%) | estimated | +| **Mainstream practitioner** | **~51%** (46–56%) | estimated | +| **Essentials user** | **~65%** (60–70%) | estimated | | Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated | | Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated | @@ -25,15 +25,15 @@ Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%): | Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---:|---:|---| | Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem | -| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | -| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | +| Mainstream practitioner | **~51%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | +| Essentials user | **~65%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is -estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF +estimated rather than measured, CR3 decodes on 26 verified Canon bodies (rarer CRX variants still open as previews), compressed ORF and HE NEF open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below -the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of -raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows +the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (compressed ORF and HE NEF open as previews; Canon, +~30% of raw users, decodes CR3 on 26 verified bodies). It passes the alpha gate: all six core workflows (import, cull, develop, save and reopen, batch looks, export) work end to end on macOS ([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in [target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user). @@ -45,11 +45,11 @@ 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 | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | -| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-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% | 32–54 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) | -| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | +| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | | Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | | Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | | Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | @@ -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%), Georgian (100%). ## Upcoming @@ -105,7 +105,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ 1. Camera colour of our own: measured calibration for the popular models (50–90 h) 2. CR3 for every Canon body (25–45 h) 3. Render-fidelity suite against Lightroom, then tuning (30–60 h) -4. Full-resolution zoom (8–15 h) +4. ~~Full-resolution zoom~~ (done 2026-10-12) 5. AI masks: Subject / Sky / People (40–70 h + model decision) 6. Compressed ORF and HE NEF (28–55 h) 7. Lens profile database of our own (40–80 h) @@ -114,8 +114,26 @@ 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. +- 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models + verified, 35 unsupported; headline numbers unchanged. +- 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each + file's preview; 3 bodies verified on CC0 files. 240 models verified; headline numbers unchanged. - 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged. +- 2026-10-12: 22 more Canon CR3 bodies verified (EOS R, RP, R3, R5, R6, R6 Mark II / III, R7, R10, R50, + R50 V, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, 1D X Mark III, PowerShot G5 X II, G7 X III, SX70, + V1): one CC0 file each pinned and checked against the camera JPEG; all 110 CC0 CR3s on raw.pixls.us + decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 259 models verified; RAW + decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%. +- 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked + against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800 + decoding wrongly. 354 models verified, ~58% of photographers on a verified body; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models @@ -132,6 +150,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ / localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export (#646), Lightroom-like Auto (#628), activity stack. +- 2026-10-11: Sony ILCA-99M2 (α99 II) camera colour: bundled profile from 144 photos; on 30 withheld photos + mean ΔE vs the camera JPEG 5.11 → 4.87, 25 of them closer. - 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW, contact-sheet PDF export, Ukrainian and French UI, release v0.5.0. @@ -163,7 +183,20 @@ 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 | +| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | +| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged | +| 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 | Georgian (`ka`) interface language added (every catalog message) | +| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; 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 | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | @@ -173,3 +206,5 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | 2026-10-08 | minor | *Where we stand* by dimension and by kind of user | | 2026-10-02 | major | Parity estimate and effort table | | 2026-09-30 | major | Created | +| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (334 models, 23%); headline numbers unchanged | +| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36's CC0 per-model checks) | diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0f..01dae3055e 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -3,9 +3,9 @@ //! ```text //! lightcraft-cli run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]… //! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] -//! lightcraft-cli render -o [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q] +//! lightcraft-cli render -o [--set control=value]… [--settings FILE.json|FILE.lcpreset] [--preset ID] [--size N] [--quality Q] //! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…] -//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… +//! lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES… //! lightcraft-cli synth-merge hdr|panorama -o DIR //! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu] //! lightcraft-cli commands [--json] @@ -17,7 +17,7 @@ mod alloc_release; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use std::process::ExitCode; @@ -55,7 +55,8 @@ USAGE: Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the same encoder as the app's Export dialog. Options: --set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5) - --settings FILE merge a partial develop-settings JSON file + --settings FILE merge a partial develop-settings JSON file, or apply the one preset of a + .lcpreset (preset.export) or XMP preset --preset ID apply a preset (see `commands`/presets.list) --size N long edge in pixels (default: full size, cropped) --quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92) @@ -79,9 +80,10 @@ USAGE: -o, --output OUT PNG path (a final screenshot is written here if the script took none) --size WxH window size in points (default 1600x1000) --scale S pixels per point (default 1) - lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… + lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES… Photo Merge: writes -HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and - prints the result as JSON. Options: + prints the result as JSON. hdr-batch merges each bracket set among the files (photos taken + less than a second apart at different exposures), e.g. a whole folder. Options: --deghost none|low|medium|high --no-align --bracket N (HDR panorama) --projection auto|spherical|cylindrical|perspective --boundary-warp 0..100 --auto-crop --fill-edges --no-auto-settings @@ -364,8 +366,7 @@ fn mcp(args: &[String]) -> Result<(), String> { }; eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe()); let mut server = Server::new(backend).with_command_tools(!compact); - let stdin = std::io::stdin(); - server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string()) + server.serve_stdio(std::io::stdout()).map_err(|e| e.to_string()) } fn merge(args: &[String]) -> Result<(), String> { @@ -373,7 +374,8 @@ fn merge(args: &[String]) -> Result<(), String> { Some("hdr") => "merge.hdr", Some("panorama" | "pano") => "merge.panorama", Some("hdr-panorama") => "merge.hdrPanorama", - _ => return Err("merge: expected hdr, panorama or hdr-panorama".into()), + Some("hdr-batch") => "merge.hdrBatch", + _ => return Err("merge: expected hdr, panorama, hdr-panorama or hdr-batch".into()), }; let mut p = serde_json::Map::new(); let mut files = Vec::new(); @@ -635,11 +637,41 @@ fn connect_port(addr: &str) -> &str { addr.rsplit_once(':').map_or(addr, |(_, port)| port) } +/// What `render --settings FILE` applies: a partial develop-settings object, or the one preset of +/// a preset file (`.lcpreset` from `preset.export`, XMP), applied as the app applies it. Merging a +/// `.lcpreset` as settings dropped its wrapper and applied nothing (#1730). +enum SettingsFile { + Partial(Value), + Preset(lightcraft_engine::develop::Preset), +} + +fn read_settings(p: &str) -> Result { + use lightcraft_engine::presets::{LCPRESET_EXT, parse_preset_file}; + let bytes = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?; + let ext = Path::new(p).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + let json = serde_json::from_slice::(&bytes); + // a settings object never has a top-level `presets` or `settings` key; a preset file always does + let preset_file = + ext == LCPRESET_EXT || ext == "xmp" || json.as_ref().is_ok_and(|v| v.is_array() || v.get("presets").is_some() || v.get("settings").is_some()); + if preset_file { + let mut list = parse_preset_file(p, &bytes).map_err(|e| format!("{p}: {e}"))?; + return match (list.pop(), list.len()) { + (Some(preset), 0) => Ok(SettingsFile::Preset(preset)), + (None, _) => Err(format!("{p}: no presets in this file")), + (Some(_), n) => Err(format!("{p}: {} presets in this file; --settings applies one, export it on its own", n + 1)), + }; + } + match json.map_err(|e| format!("{p}: {e}"))? { + v @ Value::Object(_) => Ok(SettingsFile::Partial(v)), + _ => Err(format!("{p}: not a develop-settings object or a preset file")), + } +} + fn render(args: &[String]) -> Result<(), String> { let mut input = None; let mut output = None; let mut values = serde_json::Map::new(); - let mut settings: Option = None; + let mut settings = None; let mut preset = None; let mut size: Option = None; let mut quality = 92u8; @@ -654,11 +686,7 @@ fn render(args: &[String]) -> Result<(), String> { let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?; values.insert(k.trim().to_string(), json!(v)); } - "--settings" => { - let p = take_value(args, &mut i, "--settings")?; - let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?; - settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?); - } + "--settings" => settings = Some(read_settings(take_value(args, &mut i, "--settings")?)?), "--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()), "--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?), "--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?, @@ -684,8 +712,14 @@ fn render(args: &[String]) -> Result<(), String> { if let Some(p) = preset { run(&mut s, "preset.apply", json!({"id": p}))?; } - if let Some(st) = settings { - run(&mut s, "develop.merge", json!({"settings": st}))?; + match settings { + Some(SettingsFile::Partial(st)) => run(&mut s, "develop.merge", json!({"settings": st}))?, + Some(SettingsFile::Preset(mut p)) => { + p.id = "cli.settings".into(); + s.presets.push(p); + run(&mut s, "preset.apply", json!({"id": "cli.settings"}))?; + } + None => {} } if !values.is_empty() { run(&mut s, "develop.set", json!({"values": values}))?; @@ -763,12 +797,15 @@ fn snapshot(args: &[String]) -> Result<(), String> { let t0 = Instant::now(); let mut session = match &library { Some(dir) => { - let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + let mut s = + Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(); s.open_library(dir, false).map_err(|e| library_error(dir, e))?; s } - None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(), - None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(), + None if files.is_empty() => { + Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates() + } + None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(), }; // Apply compute policy before imports or the first scripted query can discover an adapter. session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?; @@ -791,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; @@ -832,7 +871,17 @@ fn snapshot(args: &[String]) -> Result<(), String> { match next_path(&mut shots) { Some(p) => { wrote_output = true; - params["path"] = json!(p); + // `params` is untrusted script JSON: `params["path"] = …` panics on + // an array or a string. Only an object (or null, like an absent + // `params`) may take the path. + if params.is_null() { + params = json!({}); + } + if let Some(o) = params.as_object_mut() { + o.insert("path".into(), json!(p)); + } else { + return Err(format!("{script}:{}: ui.screenshot: `params` must be a JSON object", n + 1)); + } } None => return Err(format!("{script}:{}: ui.screenshot needs a `path` (or give -o)", n + 1)), } diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index bf56b02ab4..8f56bafe66 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(); @@ -139,6 +181,51 @@ fn render_subcommand() { assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control")); } +/// Issue #1730: `render --settings` took a `.lcpreset` from `preset.export`, ignored its wrapper +/// and rendered the photo unedited. +#[test] +fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() { + let input = tmp("lcpreset-in.png"); + gradient_png(&input); + let preset = tmp("bright.lcpreset"); + let (ok, lines, err) = run_cli( + &[ + "--import", + input.to_str().unwrap(), + "develop.set", + "control=light.exposure", + "value=1", + "preset.create", + "name=Bright", + "preset.export", + &format!("path={}", preset.display()), + ], + None, + ); + assert!(ok, "{err}: {lines:?}"); + let plain = tmp("lcpreset-plain.png"); + let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap(); + assert!(st.success()); + let bright = tmp("lcpreset-bright.png"); + let o = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", bright.to_str().unwrap(), "--settings", preset.to_str().unwrap()]) + .output() + .unwrap(); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(mean(&bright) > mean(&plain) + 10.0, "the preset's exposure was not applied"); + + let other = tmp("other.lcpreset"); + std::fs::write(&other, r#"{"format": "other", "version": 1, "presets": []}"#).unwrap(); + let out = tmp("lcpreset-other.png"); + let o = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--settings", other.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!o.status.success(), "an unreadable settings file was accepted"); + assert!(String::from_utf8_lossy(&o.stderr).contains("other.lcpreset"), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(!out.exists(), "nothing is written when the settings can't be read"); +} + /// Issue #93: `render IMG -o IMG` replaced its own input with the render. #[test] fn render_refuses_to_overwrite_its_input() { @@ -273,6 +360,24 @@ fn snapshot_script_failure_exits_non_zero() { assert_eq!((d.width, d.height), (320, 200)); } +/// A script line whose `params` is not an object must be an error, never a panic: the +/// `ui.screenshot`-without-a-path branch used to index (`params["path"] = …`) an array and abort +/// the whole script. +#[test] +fn snapshot_script_with_non_object_params_is_an_error() { + let script = tmp("snap-bad-params.jsonl"); + let out = tmp("snap-bad-params.png"); + std::fs::write(&script, format!("{}\n", json!({"method": "ui.screenshot", "params": [1]}))).unwrap(); + let o = Command::new(BIN) + .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "{stderr}"); + assert!(!stderr.contains("panicked"), "a bad `params` must be an error, not a panic: {stderr}"); + assert!(stderr.contains("`params` must be a JSON object"), "{stderr}"); +} + #[test] fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() { let script = tmp("snap-ui-zoom.jsonl"); diff --git a/apps/lightcraft-cli/tests/mcp_shutdown.rs b/apps/lightcraft-cli/tests/mcp_shutdown.rs new file mode 100644 index 0000000000..29d1ace937 --- /dev/null +++ b/apps/lightcraft-cli/tests/mcp_shutdown.rs @@ -0,0 +1,38 @@ +//! A broken protocol output must not require the client to close its input pipe. + +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[test] +fn broken_stdout_exits_with_stdin_still_open() { + let mut child = Command::new(env!("CARGO_BIN_EXE_lightcraft-cli")) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + drop(child.stdout.take()); + input.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":").unwrap(); + input.flush().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("MCP did not exit after stdout failed while stdin stayed open"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let mut stderr = String::new(); + child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap(); + assert!(stderr.contains("serving MCP on stdio"), "CLI must reach the MCP transport: {stderr}"); + assert!(!status.success(), "broken protocol output must be reported: {stderr}"); + assert!(stderr.contains("lightcraft-cli:"), "CLI must report the transport error: {stderr}"); + drop(input); +} diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 18c8963670..6a4487d097 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -219,6 +219,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen save_keyword_list: None, pick_curve_preset_files: None, save_curve_preset_file: None, + pick_slideshow_templates: None, + save_slideshow_template: None, write: Some(Box::new(download)), // downloads happen on the main thread: exports run in the foreground on the web write_shared: None, diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index b3d685f5fc..08f9e898d3 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -41,6 +41,9 @@ pub struct WireJob { pub as_shot_temp: f64, pub as_shot_tint: f64, pub lens: Option, + /// The original's long edge (`SourceInfo::native_long`). + #[serde(default)] + pub native_long: Option, pub settings: Arc, pub max_w: usize, pub max_h: usize, @@ -80,6 +83,7 @@ impl WireJob { as_shot_temp: job.info.as_shot_temp, as_shot_tint: job.info.as_shot_tint, lens: job.info.lens, + native_long: job.info.native_long, settings: job.settings.clone(), max_w: job.request.max_w, max_h: job.request.max_h, @@ -101,6 +105,7 @@ impl WireJob { lens: self.lens, relative_wb: self.relative_wb, legacy_relative_wb: self.legacy_relative_wb, + native_long: self.native_long, ..Default::default() } } @@ -469,6 +474,7 @@ mod tests { as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, + native_long: None, settings: Arc::new(DevelopSettings::default()), max_w: 32, max_h: 32, diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index f1523c3774..a5e64886e3 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -161,6 +161,8 @@ struct Busy { level: SourceLevel, key: u64, cache: Option<(Arc, Hash128)>, + /// A slideshow slide: the worker renders the photo, it is composed here when it comes back. + slide: Option>, } struct W { @@ -370,6 +372,13 @@ impl Workers { }); } g.remote_done += 1; + let rendered = match (rendered, &b.slide) { + (Ok(mut r), Some(slide)) => { + r.image = slide.compose(&r.image); + Ok(r) + } + (r, _) => r, + }; g.done.push(( b.slot, RenderResult { @@ -493,6 +502,7 @@ impl RenderOffload for Workers { level: job.level, key: job.key, cache: job.cache, + slide: job.slide, }); None } diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index bbe495c7d1..26122c3e62 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/appearance.rs b/apps/lightcraft/src/appearance.rs index 74333a3010..223dbc5604 100644 --- a/apps/lightcraft/src/appearance.rs +++ b/apps/lightcraft/src/appearance.rs @@ -51,7 +51,7 @@ fn decode(code: u32) -> Option { /// absolute path, and is never repeated. #[cfg(target_os = "linux")] fn gtk_theme() -> Option { - let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let exe = gsettings_exe(std::env::var_os("PATH").as_deref())?; let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; if !output.status.success() { return None; @@ -59,6 +59,15 @@ fn gtk_theme() -> Option { parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) } +/// `gsettings` by absolute path: the absolute `PATH` entries first (NixOS has no /usr/bin/gsettings), +/// then the usual places for a sparse `PATH`. Relative entries are skipped, so nothing runs from +/// the working folder. +#[cfg(target_os = "linux")] +fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option { + let listed = path.map(std::env::split_paths).into_iter().flatten().filter(|dir| dir.is_absolute()); + listed.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|dir| dir.join("gsettings")).find(|exe| exe.is_file()) +} + #[cfg(target_os = "linux")] fn parse_gtk_scheme(s: &str) -> Option { match s.trim().trim_matches('\'') { @@ -174,6 +183,24 @@ mod tests { assert_eq!(calls.load(Ordering::Relaxed), after_settle); } + /// NixOS keeps `gsettings` in /run/current-system/sw/bin or the per-user profile: it is found + /// on `PATH`, but never through a relative entry, even one that leads to a real file. + #[cfg(target_os = "linux")] + #[test] + fn gsettings_is_found_on_absolute_path_entries_only() { + let dir = std::env::temp_dir().join(format!("lightcraft-gsettings-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("gsettings"), "").unwrap(); + let path = std::env::join_paths([std::path::Path::new("relative/bin"), &dir]).unwrap(); + assert_eq!(super::gsettings_exe(Some(&path)), Some(dir.join("gsettings"))); + let cwd = std::env::current_dir().unwrap(); + let relative = std::path::PathBuf::from("../".repeat(cwd.components().count() - 1)).join(dir.strip_prefix("/").unwrap()); + assert!(relative.join("gsettings").is_file()); + let found = super::gsettings_exe(Some(relative.as_os_str())); + assert!(found.as_ref().is_none_or(|p| p.is_absolute()), "{found:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[cfg(target_os = "linux")] #[test] fn portal_values_and_gsettings_output_decode() { diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index e0cd35576e..95739b3e97 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -81,6 +81,8 @@ impl eframe::App for App { self.0.ui(ui); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + // (a size the window was just given is remembered too) + self.0.ui.window = self.1.window_at_exit(self.0.ui.window); if let Err(e) = self.1.save(&self.0) { log::error!("{e}"); } @@ -163,6 +165,46 @@ fn gpu_crash_notice(what: &str) -> String { ) } +/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next +/// launch renders on the CPU and says why. +fn gpu_lost_marker_path() -> Option { + gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker")) +} + +/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a +/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it +/// never failed. +fn gpu_lost_reason() -> Option { + lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string)) +} + +/// Record that LightCraft stopped after the GPU was lost, for the next launch. +fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) { + let Some(m) = marker else { return }; + if let Some(d) = m.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Err(e) = std::fs::write(m, format!("{reason}\n")) { + log::warn!("could not write {}: {e}", m.display()); + } +} + +/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a +/// `--memory` session, which leaves the disk as it found it). +fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option { + let m = marker?; + let reason = std::fs::read_to_string(m).ok()?; + if !in_memory { + let _ = std::fs::remove_file(m); + } + Some(format!( + "LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \ + is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \ + an up-to-date graphics driver may help.", + reason.trim() + )) +} + /// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's /// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. fn display_profile_dir() -> Option { @@ -184,6 +226,16 @@ fn config_dir() -> Option { lightcraft_engine::config::config_dir() } +/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size +/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it +/// opens is the system's to say. +fn as_remembered(builder: egui::ViewportBuilder, window: Option) -> egui::ViewportBuilder { + let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size)); + // (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen + // to a size of its own choosing) + if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder } +} + /// `/ui.json`, the saved UI state and app settings — `None` when nothing is read or /// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder. fn prefs_path() -> Option { @@ -302,6 +354,11 @@ struct PrefsWriter { failing: bool, /// `ui.json` couldn't be read at launch: never overwrite it this session. keep_file: bool, + /// Follows the window, to remember the size it rests at (`UiState::window`). + window: lightcraft_ui_egui::window_place::WindowWatch, + /// When the window was first looked at: its watch goes by the clock from then on, not by + /// the interface's time, which stands still while the window is hidden. + window_since: Option, } impl PrefsWriter { @@ -332,8 +389,33 @@ impl PrefsWriter { Ok(()) } + /// What to remember of the window as the app quits ([`WindowWatch::at_exit`]). + /// + /// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit + fn window_at_exit( + &mut self, + remembered: Option, + ) -> Option { + match self.window_since { + Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()), + None => remembered, + } + } + fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + // (asked for before the input is read: the context is not asked from inside its own lock) + let zoom = ctx.zoom_factor(); + let (now, seen) = ctx.input(|i| (i.time, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom))); + // how the window is, to open like that next time (written with the settings below). What + // the settings hold is checked first: a script may have set it (`ui.set`) + let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); + let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64(); + app.ui.window = self.window.note(remembered, &seen, at); + if let Some(wait) = self.window.waits(at) { + // (a size is only remembered once the window has rested at it: look again then, though + // nothing else asks for a frame) + ctx.request_repaint_after(wait); + } let moved = app.ui.settings.library_path != self.library; if !moved && now - self.checked < 3.0 { return; @@ -604,6 +686,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> .save_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_slideshow_templates: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_slideshow_template: Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), // atomic (temp file + sync + rename): a failed write never leaves a truncated file write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), write: Some(Box::new(lightcraft_engine::export::write_file)), @@ -617,10 +717,12 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> } } -/// The library session with the folder face models are kept in (`/models`). +/// The library session with the folders face models (`/models`) and slideshow templates +/// (`/slideshow-templates`) are kept in. fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { let (mut s, problem) = open_library_session(in_memory, dir, seed_demo); s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); + s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); // Control requests are answered on the UI thread: Add to Apple Photos must not wait there s.apple_photos_background = true; (s, problem) @@ -809,7 +911,7 @@ fn main() -> eframe::Result { let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); // GPU compute: off if the preference says so, or if the last launch died creating the device // (issue #136) — before anything can create it - let gpu_crash = gpu_crash_check(in_memory); + let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory)); let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none(); lightcraft_engine::gpu::set_enabled(gpu_on); // the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't @@ -818,12 +920,15 @@ fn main() -> eframe::Result { let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files); win_files.arm(window_renderer); let win_files_app = win_files.clone(); + // the window came up: a failure after this is LightCraft stopping, not a start that failed + let came_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let came_up_app = came_up.clone(); + // how the window was when the app was last used (checked: the settings file is a file) + let opens_as = prefs.as_ref().and_then(|ui| ui.window).and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); let options = eframe::NativeOptions { renderer, - viewport: egui::ViewportBuilder::default() - .with_title("LightCraft") - .with_inner_size([1600.0, 1000.0]) - .with_min_inner_size([900.0, 560.0]) + viewport: as_remembered(egui::ViewportBuilder::default().with_title("LightCraft"), opens_as) + .with_min_inner_size(lightcraft_ui_egui::window_place::MIN_SIZE) .with_drag_and_drop(true) .with_fullsize_content_view(true) .with_titlebar_shown(false) @@ -855,6 +960,7 @@ fn main() -> eframe::Result { Box::new(move |cc| { // the window's renderer is up (eframe calls this after creating it) win_files_app.started(window_renderer); + came_up_app.store(true, std::sync::atomic::Ordering::Relaxed); let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) @@ -872,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() }, @@ -916,6 +1027,31 @@ fn main() -> eframe::Result { }), ) })); + if came_up.load(std::sync::atomic::Ordering::Relaxed) { + // the window was up: a panic that escaped it now is LightCraft stopping, typically the + // window's renderer after the graphics driver reset the GPU (issue #1337: egui-wgpu panics + // on the lost device). Not a failed start: no other renderer is tried, and the message + // says what happened. + let stopped = match started { + Ok(r) => r, + Err(_) => Err(eframe::Error::AppCreation("its window stopped drawing (the renderer panicked)".into())), + }; + if let Err(e) = &stopped { + let lost = gpu_lost_reason(); + if let Some(reason) = &lost + && !in_memory + { + record_gpu_lost(gpu_lost_marker_path().as_deref(), reason); + } + // (not "app creation error: …": the app had been created) + let what = match e { + eframe::Error::AppCreation(m) => m.to_string(), + e => e.to_string(), + }; + stopped_while_running(&stopped_text(&what, lost.as_deref()), log_file.as_deref()); + } + return stopped; + } // a panic that still escaped the renderer's start (the probe and error handler should // prevent it): an error like any other failed start let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into()))); @@ -930,6 +1066,34 @@ fn main() -> eframe::Result { started } +/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU +/// was lost (`lost`): GPU rendering is off from the next start. +fn stopped_text(error: &str, lost: Option<&str>) -> String { + match lost { + Some(reason) => format!( + "{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \ + Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on" + ), + None => error.to_string(), + } +} + +/// LightCraft stopped while its window was up: say so in the log and in a native message box +/// naming the log file (as [`startup_failed`] does for a start that failed). +fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) { + log::error!("LightCraft stopped: {text}"); + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return; // tests and scripts: no dialog to click away + } + let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref()); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(title) + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + /// The window could not start (e.g. no usable graphics device): say so in the log and in a native /// message box naming the log file, instead of exiting without a trace — the release builds have /// no console on Windows, and an app started from Finder shows no standard error (issue #260). @@ -1132,6 +1296,37 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an + /// export, and the dialog said the window "could not open". The stop is recorded; the next + /// launch says why once and renders on the CPU (a `--memory` session leaves the record). + #[test] + fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() { + let d = dir("gpu-lost"); + let m = d.join("gpu-lost.marker"); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded"); + assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder"); + record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost"); + let notice = gpu_lost_check_at(Some(&m), true).unwrap(); + assert!( + notice.contains("(device lost (Unknown): Device is lost)") + && notice.contains("GPU rendering is now off") + && notice.contains("Use the GPU for rendering"), + "{notice}" + ); + assert!(m.exists(), "a --memory session leaves it"); + assert!(gpu_lost_check_at(Some(&m), false).is_some()); + assert!(!m.exists()); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once"); + // the message box: LightCraft stopped (not "could not open its window"), and why + let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost")); + assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}"); + assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing"); + let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log")); + assert_eq!(title, "LightCraft stopped"); + assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}"); + let _ = std::fs::remove_dir_all(&d); + } + /// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. #[test] fn gpu_crash_marker_is_reported_once() { @@ -1238,6 +1433,96 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Given what the settings remember of the window, when the app's window is built, then it + /// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it + /// opens at the usual size. + #[test] + fn the_window_opens_as_it_was() { + use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace}; + let fresh = as_remembered(egui::ViewportBuilder::default(), None); + assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None)); + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true))); + // (not asked to un-zoom: macOS would then resize a window that fills its screen) + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None)); + } + + /// Given a window that rests at a size, when the settings are written and read again, then + /// they hold how it was. A window in the file that is not one (a hand edit, another version's) + /// reads as nothing remembered and costs no other setting; and a window resized just before + /// the app quits is remembered at that size. + #[test] + fn the_settings_keep_how_the_window_was() { + use lightcraft_ui_egui::window_place::{Seen, WindowPlace}; + let d = dir("window"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.ui.settings.library_path = "/pictures/library".into(); + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + writer.save(&app).unwrap(); + assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet"); + let window = WindowPlace { size: [1300.0, 800.0], zoomed: true }; + app.ui.window = Some(window); + writer.save(&app).unwrap(); + let (restored, warning, _) = load_prefs_at(&path, false); + assert_eq!((restored.unwrap().window, warning), (Some(window), None)); + + // a window that is no window: the file is read all the same, with its other settings + let text = std::fs::read_to_string(&path).unwrap(); + for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] { + let start = text.find("\"window\"").unwrap(); + let end = start + text[start..].find('}').unwrap() + 1; + std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap(); + let (restored, warning, keep) = load_prefs_at(&path, true); + let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}")); + assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}"); + } + + // resized, and the app quits a moment later: the new size is what is written + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }); + writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0); + writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400)); + assert!(writer.window_since.is_some()); + assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false })); + assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen"); + let _ = std::fs::remove_dir_all(&d); + } + + /// Given the app's window at a size, when the settings writer looks at it frame after frame, + /// then it asks to be woken while the size has still to rest, remembers the size once it has, + /// and asks for nothing more. A window in the settings that is not one (a script set it) is + /// put right before it is gone by. + #[test] + fn the_writer_follows_the_window_from_frame_to_frame() { + use lightcraft_ui_egui::window_place::WindowPlace; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut writer = PrefsWriter::default(); + let ctx = egui::Context::default(); + let pass = |look: &mut dyn FnMut(&egui::Context)| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| look(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay) + }; + // (egui's own first frames ask for another: let them pass) + for _ in 0..3 { + pass(&mut |_| {}); + } + app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true }); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, None, "no window, and not yet at rest"); + assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}"); + // a second later + writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1))); + pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}"); + } + /// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. /// The check runs in a child (this test binary again), since on macOS it ends the process. #[test] diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 256faa1f81..4ac40bcacb 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -26,8 +26,9 @@ const QUIT: &str = "app.quit"; const SETTINGS: &str = "app.settings"; const SETTINGS_KEY: &str = "Cmd+,"; /// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo -/// or the active mask): left to the egui handler, which knows the context. -const CONTEXTUAL: &[&str] = &["X", "Delete"]; +/// or the active mask; ⌘↑/⌘↓ flag status follows Compare/Survey's active photo): left to the egui +/// handler, which knows the context. +const CONTEXTUAL: &[&str] = &["X", "Delete", "Cmd+Up", "Cmd+Down"]; /// Shortcuts text fields need while they have focus. const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"]; @@ -126,6 +127,7 @@ fn accelerator(sc: &str) -> Option { "=" => Code::Equal, "-" => Code::Minus, "'" => Code::Quote, + "`" => Code::Backquote, "," => Code::Comma, "Delete" => Code::Backspace, // with modifiers only: a bare Enter belongs to text fields and tools diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 66e5a78d79..1f84ec4775 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 7ade9dac2e..7f8a8a719d 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -12,20 +12,28 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none | | `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` | +| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none | | `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | +| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | +| `crates/ui-egui/src/panels/person.rs` | Up arrow on the People page bulk-confirm control, drawn in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-10-13 | n/a | | `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*-migrant-mother.jpg`, `docs/images/grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | -| 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 | 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 ships Noto Sans CJK SC Regular as a separate font file | +| 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 | +| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are diff --git a/assets/camera-profiles/ILCA-99M2.json b/assets/camera-profiles/ILCA-99M2.json new file mode 100644 index 0000000000..ff18a63c17 --- /dev/null +++ b/assets/camera-profiles/ILCA-99M2.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "ILCA-99M2", + "files": 144, + "samples": 501511, + "matrix": [ + [1.1432775892083846, -0.27864150084892714, 0.13786480817829982], + [-0.05589204732762987, 1.1699605558717394, -0.11694884297799035], + [0.004313413220749851, -0.7088386949439993, 1.7263782534882033] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [2.804254, 1.2235245, 1.0], + [5.0115886, 1.2228547, 1.0], + [5.1745224, 1.1897815, 1.0], + [3.4398973, 1.0871476, 1.0], + [0.0, 1.0, 1.0], + [2.1009562, 1.231483, 1.0], + [3.2371128, 1.1828748, 1.0], + [3.6905267, 1.1272595, 1.0], + [3.2581418, 1.0763286, 1.0], + [0.0, 1.0, 1.0], + [1.3609977, 1.2428942, 1.0], + [1.1722605, 1.1633604, 1.0], + [1.2722441, 1.0881792, 1.0], + [0.99433255, 1.026622, 1.0], + [0.0, 1.0, 1.0], + [-0.12375347, 1.2171124, 1.0], + [-0.6004144, 1.1662252, 1.0], + [-1.2418907, 1.1001159, 1.0], + [-1.3929275, 1.017488, 1.0], + [0.0, 1.0, 1.0], + [-1.4741973, 1.1985689, 1.0], + [-2.3110185, 1.1587616, 1.0], + [-3.2606716, 1.1098144, 1.0], + [-3.9140587, 1.0290464, 1.0], + [0.0, 1.0, 1.0], + [-2.748514, 1.1680661, 1.0], + [-3.8273644, 1.1325561, 1.0], + [-4.7215014, 1.0942773, 1.0], + [-5.6006517, 1.0109093, 1.0], + [0.0, 1.0, 1.0], + [-3.6385696, 1.1548748, 1.0], + [-4.8762474, 1.106731, 1.0], + [-5.845225, 1.0598869, 1.0], + [-6.7829485, 0.9773247, 1.0], + [0.0, 1.0, 1.0], + [-4.4043136, 1.1618558, 1.0], + [-5.663378, 1.1124074, 1.0], + [-6.676991, 1.0622271, 1.0], + [-7.2388797, 0.9792954, 1.0], + [0.0, 1.0, 1.0], + [-4.5636735, 1.1730139, 1.0], + [-5.5787325, 1.131743, 1.0], + [-6.434246, 1.0818648, 1.0], + [-7.713659, 0.977632, 1.0], + [0.0, 1.0, 1.0], + [-4.1642222, 1.1961858, 1.0], + [-5.135349, 1.1508327, 1.0], + [-5.9561267, 1.1025041, 1.0], + [-7.8126693, 0.9789329, 1.0], + [0.0, 1.0, 1.0], + [-3.3540795, 1.2313552, 1.0], + [-4.0874486, 1.1859667, 1.0], + [-5.090505, 1.1065466, 1.0], + [-6.980868, 0.94789493, 1.0], + [0.0, 1.0, 1.0], + [-3.2277284, 1.2495877, 1.0], + [-3.6526108, 1.1937634, 1.0], + [-3.5733712, 1.0832378, 1.0], + [-5.550715, 0.9253031, 1.0], + [0.0, 1.0, 1.0], + [-3.2758887, 1.2372755, 1.0], + [-2.8500462, 1.1516474, 1.0], + [-1.8038367, 1.0081928, 1.0], + [-2.1356683, 0.93026036, 1.0], + [0.0, 1.0, 1.0], + [-2.842822, 1.1995343, 1.0], + [-1.5608833, 1.0880193, 1.0], + [-0.6414115, 0.96593964, 1.0], + [-0.8170321, 0.9045082, 1.0], + [0.0, 1.0, 1.0], + [-1.8589126, 1.1368302, 1.0], + [-0.5850774, 1.0399925, 1.0], + [-0.4096108, 0.94941473, 1.0], + [-0.67716926, 0.89125115, 1.0], + [0.0, 1.0, 1.0], + [-0.50222045, 1.0790738, 1.0], + [0.13412993, 1.0091311, 1.0], + [0.04161173, 0.94268256, 1.0], + [0.21809827, 0.89096487, 1.0], + [0.0, 1.0, 1.0], + [0.5395973, 1.0523564, 1.0], + [0.68198884, 0.9942523, 1.0], + [0.8921194, 0.93612367, 1.0], + [1.5682732, 0.8875503, 1.0], + [0.0, 1.0, 1.0], + [1.4230179, 1.0399433, 1.0], + [1.7522293, 0.98858124, 1.0], + [2.120758, 0.93895113, 1.0], + [2.7880068, 0.8917508, 1.0], + [0.0, 1.0, 1.0], + [2.485209, 1.0362785, 1.0], + [3.2553241, 0.9826427, 1.0], + [3.6980822, 0.9420671, 1.0], + [3.6589043, 0.9088622, 1.0], + [0.0, 1.0, 1.0], + [3.8520021, 1.0283107, 1.0], + [4.4029727, 0.97790647, 1.0], + [4.6192904, 0.941655, 1.0], + [3.3324108, 0.9242873, 1.0], + [0.0, 1.0, 1.0], + [4.579471, 1.0396363, 1.0], + [5.1236196, 0.99359185, 1.0], + [4.4844737, 0.9637591, 1.0], + [0.93364024, 0.9805732, 1.0], + [0.0, 1.0, 1.0], + [5.6343756, 1.0391579, 1.0], + [5.9764285, 0.9963135, 1.0], + [4.2140217, 0.97128063, 1.0], + [0.31541073, 0.99498296, 1.0], + [0.0, 1.0, 1.0], + [6.3622503, 1.0628132, 1.0], + [5.916998, 1.0203458, 1.0], + [1.3126858, 0.9978764, 1.0], + [-0.040236503, 0.99987936, 1.0], + [0.0, 1.0, 1.0], + [6.1092844, 1.1148107, 1.0], + [5.2341003, 1.0724294, 1.0], + [0.57583106, 1.0097842, 1.0], + [-0.004625028, 1.0000128, 1.0], + [0.0, 1.0, 1.0], + [5.6949396, 1.1773212, 1.0], + [3.8311253, 1.1038187, 1.0], + [-0.11847352, 1.0086719, 1.0], + [-0.0105433175, 1.000084, 1.0], + [0.0, 1.0, 1.0], + [4.546873, 1.1809658, 1.0], + [0.6606819, 1.0607728, 1.0], + [-1.8319762, 0.98954993, 1.0], + [-0.11198713, 0.9991004, 1.0], + [0.0, 1.0, 1.0], + [2.975616, 1.171921, 1.0], + [-2.6058009, 1.0177505, 1.0], + [-5.031273, 0.9486056, 1.0], + [-0.71531844, 0.99169016, 1.0], + [0.0, 1.0, 1.0], + [2.3296447, 1.1538781, 1.0], + [-4.7061315, 0.9983956, 1.0], + [-7.1538033, 0.9327583, 1.0], + [-1.329432, 0.9859865, 1.0], + [0.0, 1.0, 1.0], + [1.33782, 1.1317362, 1.0], + [-5.738668, 0.9846136, 1.0], + [-8.032541, 0.93113464, 1.0], + [-1.414409, 0.9868347, 1.0], + [0.0, 1.0, 1.0], + [1.1102281, 1.1464978, 1.0], + [-4.347359, 1.0098535, 1.0], + [-5.8042417, 0.9558565, 1.0], + [-0.65224177, 0.99453866, 1.0], + [0.0, 1.0, 1.0], + [1.3978106, 1.1282454, 1.0], + [-2.5084882, 1.0191262, 1.0], + [-2.5297656, 0.9832681, 1.0], + [-0.15988652, 0.99876547, 1.0], + [0.0, 1.0, 1.0], + [1.6285329, 1.1082805, 1.0], + [-1.8376639, 1.0320127, 1.0], + [-1.5057008, 0.99416333, 1.0], + [-0.07763386, 0.99956375, 1.0], + [0.0, 1.0, 1.0], + [0.47474164, 1.1118255, 1.0], + [-2.2814848, 1.0398426, 1.0], + [-0.96154535, 0.9967108, 1.0], + [-0.038478848, 0.99972326, 1.0], + [0.0, 1.0, 1.0], + [-0.28353742, 1.1260961, 1.0], + [-2.6729789, 1.0359904, 1.0], + [-0.76547706, 0.9970432, 1.0], + [-0.02355436, 0.99970925, 1.0], + [0.0, 1.0, 1.0], + [0.65706646, 1.1279969, 1.0], + [-2.8462512, 1.0391037, 1.0], + [-0.5868237, 1.0005401, 1.0], + [-0.010716562, 0.9999053, 1.0], + [0.0, 1.0, 1.0], + [0.12108033, 1.1502995, 1.0], + [-1.9704113, 1.0370626, 1.0], + [-0.63179135, 1.0001434, 1.0], + [-0.015800219, 0.99995714, 1.0], + [0.0, 1.0, 1.0], + [1.0309358, 1.1655595, 1.0], + [0.13250555, 1.0445937, 1.0], + [-0.07715364, 1.0009365, 1.0], + [-0.0021050358, 0.99999684, 1.0], + [0.0, 1.0, 1.0], + [-0.2827152, 1.1324676, 1.0], + [0.014774211, 1.0343506, 1.0], + [-0.019916466, 1.0006523, 1.0], + [-0.00040274212, 1.0000006, 1.0], + [0.0, 1.0, 1.0], + [-2.2018666, 1.1418259, 1.0], + [-1.4933577, 1.0408006, 1.0], + [-0.3667708, 1.0002662, 1.0], + [-0.007263095, 0.9999904, 1.0], + [0.0, 1.0, 1.0], + [-2.64397, 1.1194679, 1.0], + [-1.6423713, 1.0442007, 1.0], + [-0.20484583, 1.0007143, 1.0], + [-0.0025689714, 0.99998134, 1.0], + [0.0, 1.0, 1.0], + [-3.8773065, 1.110005, 1.0], + [-2.6916754, 1.0417831, 1.0], + [-0.33602908, 1.0002605, 1.0], + [-0.0021417958, 0.9999703, 1.0], + [0.0, 1.0, 1.0], + [-3.714574, 1.0977588, 1.0], + [-3.5595996, 1.0386114, 1.0], + [-1.5827396, 1.0060165, 1.0], + [-0.028114408, 1.0000618, 1.0], + [0.0, 1.0, 1.0], + [-3.2299962, 1.0955467, 1.0], + [-3.3268821, 1.0478108, 1.0], + [-1.9395999, 1.0131217, 1.0], + [-0.075512886, 1.0002799, 1.0], + [0.0, 1.0, 1.0], + [-1.336435, 1.0960432, 1.0], + [-0.8794556, 1.0795935, 1.0], + [-0.29321554, 1.0271318, 1.0], + [0.00032340753, 1.0008185, 1.0], + [0.0, 1.0, 1.0], + [0.09420732, 1.1007514, 1.0], + [0.45092306, 1.0983796, 1.0], + [1.0769465, 1.0420003, 1.0], + [0.22761619, 1.0022283, 1.0], + [0.0, 1.0, 1.0], + [0.71772265, 1.1304002, 1.0], + [1.5783058, 1.1197016, 1.0], + [2.8271558, 1.0629567, 1.0], + [1.0452774, 1.0075884, 1.0], + [0.0, 1.0, 1.0], + [1.7700194, 1.1402828, 1.0], + [2.267522, 1.1383678, 1.0], + [2.3750477, 1.0799156, 1.0], + [0.46060455, 0.99580747, 1.0], + [0.0, 1.0, 1.0], + [2.9440982, 1.1368023, 1.0], + [3.6898718, 1.1418967, 1.0], + [3.8923314, 1.1259586, 1.0], + [1.2024251, 1.0325387, 1.0], + [0.0, 1.0, 1.0], + [4.2370567, 1.1504492, 1.0], + [4.6764765, 1.1490715, 1.0], + [3.910422, 1.1321807, 1.0], + [0.89606607, 1.0308274, 1.0], + [0.0, 1.0, 1.0], + [4.9599085, 1.134546, 1.0], + [5.1401796, 1.1103181, 1.0], + [3.7372687, 1.0921954, 1.0], + [0.55078083, 1.021753, 1.0], + [0.0, 1.0, 1.0], + [5.7153788, 1.1273357, 1.0], + [5.978767, 1.1330243, 1.0], + [3.9994862, 1.1207391, 1.0], + [0.36665595, 1.0121281, 1.0], + [0.0, 1.0, 1.0], + [6.2697597, 1.1095688, 1.0], + [5.6365104, 1.1205149, 1.0], + [3.4179032, 1.1214768, 1.0], + [0.26656342, 1.0103098, 1.0], + [0.0, 1.0, 1.0], + [6.1428804, 1.0995386, 1.0], + [3.956236, 1.1270815, 1.0], + [1.9768763, 1.1226226, 1.0], + [0.11463216, 1.0099398, 1.0], + [0.0, 1.0, 1.0], + [6.929935, 1.1041147, 1.0], + [3.3182452, 1.1096137, 1.0], + [1.2072796, 1.0818946, 1.0], + [-0.01571625, 1.005663, 1.0], + [0.0, 1.0, 1.0], + [7.265804, 1.1154059, 1.0], + [3.7104347, 1.0477185, 1.0], + [1.056846, 0.9974326, 1.0], + [0.03417005, 0.9998331, 1.0], + [0.0, 1.0, 1.0], + [7.849703, 1.1269237, 1.0], + [4.3993697, 1.0325269, 1.0], + [1.3606595, 0.99489033, 1.0], + [0.047941446, 0.99993676, 1.0], + [0.0, 1.0, 1.0], + [8.439311, 1.1298556, 1.0], + [3.303848, 1.0538055, 1.0], + [0.59302783, 1.0085934, 1.0], + [0.025294233, 1.0004109, 1.0], + [0.0, 1.0, 1.0], + [8.608997, 1.1400498, 1.0], + [3.3591669, 1.0461109, 1.0], + [0.40004572, 1.0048742, 1.0], + [0.01262109, 1.0001965, 1.0], + [0.0, 1.0, 1.0], + [6.761172, 1.1502578, 1.0], + [2.1108382, 1.0369791, 1.0], + [0.11530293, 1.0012623, 1.0], + [0.001693561, 1.0000176, 1.0], + [0.0, 1.0, 1.0], + [5.5255957, 1.1716747, 1.0], + [1.0459789, 1.037805, 1.0], + [0.09180857, 1.001637, 1.0], + [0.003279748, 1.0000362, 1.0], + [0.0, 1.0, 1.0], + [5.1823792, 1.1748279, 1.0], + [1.723147, 1.0416057, 1.0], + [0.2180456, 1.0030037, 1.0], + [0.005272273, 1.0000591, 1.0], + [0.0, 1.0, 1.0], + [5.7087555, 1.1780843, 1.0], + [2.619079, 1.0446409, 1.0], + [0.35640785, 1.0032725, 1.0], + [0.010388461, 1.0001107, 1.0], + [0.0, 1.0, 1.0], + [6.8262243, 1.1712428, 1.0], + [3.972946, 1.0097475, 1.0], + [0.6286015, 0.9970032, 1.0], + [0.017907226, 1.0001063, 1.0], + [0.0, 1.0, 1.0], + [6.524165, 1.182861, 1.0], + [3.200896, 1.0579331, 1.0], + [0.28860277, 1.0027157, 1.0], + [0.0036479658, 1.0000447, 1.0], + [0.0, 1.0, 1.0], + [5.831539, 1.1791148, 1.0], + [2.774682, 1.0702703, 1.0], + [0.35563686, 1.0059416, 1.0], + [0.0069925115, 1.0001086, 1.0], + [0.0, 1.0, 1.0], + [7.6593747, 1.1839095, 1.0], + [2.9386632, 1.0513804, 1.0], + [0.23482445, 0.999349, 1.0], + [0.0063177217, 1.0000361, 1.0], + [0.0, 1.0, 1.0], + [7.268381, 1.1659219, 1.0], + [2.5044994, 1.0421095, 1.0], + [0.20415363, 0.99924254, 1.0], + [0.0059488467, 1.0000399, 1.0], + [0.0, 1.0, 1.0], + [7.236816, 1.1485709, 1.0], + [4.5540986, 1.06799, 1.0], + [1.4449328, 1.0170963, 1.0], + [0.0639702, 1.0006945, 1.0], + [0.0, 1.0, 1.0], + [6.453048, 1.1589189, 1.0], + [6.3102107, 1.1038384, 1.0], + [3.1707234, 1.032528, 1.0], + [0.19751447, 1.00155, 1.0], + [0.0, 1.0, 1.0], + [6.372379, 1.202752, 1.0], + [7.4304466, 1.1593263, 1.0], + [5.6761837, 1.0836742, 1.0], + [0.6670318, 1.0070612, 1.0], + [0.0, 1.0, 1.0], + [5.3508396, 1.1789645, 1.0], + [7.0611863, 1.1524564, 1.0], + [7.2240868, 1.1157393, 1.0], + [2.2516217, 1.0274429, 1.0], + [0.0, 1.0, 1.0], + [4.5282054, 1.1895584, 1.0], + [6.615412, 1.2008698, 1.0], + [7.1562805, 1.1773871, 1.0], + [3.693685, 1.0640011, 1.0], + [0.0, 1.0, 1.0], + [2.989149, 1.1666261, 1.0], + [5.0449367, 1.163257, 1.0], + [5.3947783, 1.1525968, 1.0], + [4.8617563, 1.1140602, 1.0], + [0.0, 1.0, 1.0], + [2.4345527, 1.1707076, 1.0], + [4.030044, 1.1360824, 1.0], + [4.400234, 1.1141994, 1.0], + [3.9981327, 1.0895209, 1.0], + [0.0, 1.0, 1.0], + [1.6249478, 1.1449721, 1.0], + [2.3648932, 1.0950501, 1.0], + [2.5787578, 1.0738068, 1.0], + [1.557968, 1.048907, 1.0], + [0.0, 1.0, 1.0], + [1.1732466, 1.1023636, 1.0], + [1.5519327, 1.0694385, 1.0], + [0.8835925, 1.0562818, 1.0], + [-0.95382243, 1.0234519, 1.0], + [0.0, 1.0, 1.0], + [0.54519135, 1.0728073, 1.0], + [0.7171778, 1.047036, 1.0], + [-0.4340524, 1.0440289, 1.0], + [-3.1891012, 1.0054655, 1.0], + [0.0, 1.0, 1.0], + [-0.81599987, 1.0807294, 1.0], + [-1.1246754, 1.0611808, 1.0], + [-2.2497585, 1.0415246, 1.0], + [-4.411367, 0.973935, 1.0], + [0.0, 1.0, 1.0], + [-1.7861705, 1.0760409, 1.0], + [-2.835779, 1.0522164, 1.0], + [-3.9953759, 1.0148282, 1.0], + [-4.4476047, 0.9319428, 1.0], + [0.0, 1.0, 1.0], + [-2.4466016, 1.0813212, 1.0], + [-3.5611944, 1.0451691, 1.0], + [-4.5130477, 1.011627, 1.0], + [-4.224515, 0.92788553, 1.0], + [0.0, 1.0, 1.0], + [-3.003385, 1.1208798, 1.0], + [-4.1261635, 1.0943259, 1.0], + [-5.189995, 1.0589299, 1.0], + [-6.5665016, 0.95204496, 1.0], + [0.0, 1.0, 1.0], + [-2.5737576, 1.1543715, 1.0], + [-3.583895, 1.1235094, 1.0], + [-4.758327, 1.0856714, 1.0], + [-6.4036903, 0.95784587, 1.0], + [0.0, 1.0, 1.0], + [-1.783091, 1.1985654, 1.0], + [-2.344906, 1.1536651, 1.0], + [-3.4222968, 1.0852684, 1.0], + [-5.1206603, 0.9148598, 1.0], + [0.0, 1.0, 1.0], + [-1.8261324, 1.2225533, 1.0], + [-2.3288019, 1.1683235, 1.0], + [-1.7856505, 1.0476049, 1.0], + [-4.6820045, 0.88435495, 1.0], + [0.0, 1.0, 1.0], + [-1.9707233, 1.2195381, 1.0], + [-0.81782085, 1.122641, 1.0], + [0.9111474, 0.9622587, 1.0], + [-1.1497722, 0.88565606, 1.0], + [0.0, 1.0, 1.0], + [-2.09956, 1.2140585, 1.0], + [0.056621242, 1.0911676, 1.0], + [1.0300432, 0.9330744, 1.0], + [-0.18233444, 0.8674556, 1.0], + [0.0, 1.0, 1.0], + [-0.7154876, 1.144233, 1.0], + [1.4851263, 1.0231369, 1.0], + [0.555909, 0.9251492, 1.0], + [-0.18147656, 0.8636336, 1.0], + [0.0, 1.0, 1.0], + [1.3398509, 1.0599889, 1.0], + [1.6690949, 0.9969751, 1.0], + [0.37584245, 0.92953223, 1.0], + [0.07826649, 0.87178516, 1.0], + [0.0, 1.0, 1.0], + [2.508692, 1.0410949, 1.0], + [1.8833909, 0.99179685, 1.0], + [1.2507737, 0.9335844, 1.0], + [1.6161711, 0.8835863, 1.0], + [0.0, 1.0, 1.0], + [3.1758716, 1.0253911, 1.0], + [2.4910228, 0.9809797, 1.0], + [2.236299, 0.9303089, 1.0], + [2.6537087, 0.8825234, 1.0], + [0.0, 1.0, 1.0], + [3.6103837, 1.0229514, 1.0], + [3.1707804, 0.97286725, 1.0], + [3.3409152, 0.9399243, 1.0], + [3.5829213, 0.9052561, 1.0], + [0.0, 1.0, 1.0], + [4.3082023, 1.0349113, 1.0], + [4.284679, 0.9839242, 1.0], + [4.3494177, 0.9484854, 1.0], + [3.8492424, 0.9255855, 1.0], + [0.0, 1.0, 1.0], + [4.793538, 1.0573108, 1.0], + [5.6492386, 0.98787755, 1.0], + [6.214179, 0.91949385, 1.0], + [3.9530585, 0.92670876, 1.0], + [0.0, 1.0, 1.0], + [5.384946, 1.070319, 1.0], + [6.3971586, 0.98128706, 1.0], + [6.9482284, 0.89684695, 1.0], + [4.2139463, 0.9239156, 1.0], + [0.0, 1.0, 1.0], + [5.509941, 1.109065, 1.0], + [4.31208, 1.0552181, 1.0], + [-1.2347931, 0.9985642, 1.0], + [-0.86304265, 0.9993037, 1.0], + [0.0, 1.0, 1.0], + [5.1984997, 1.162435, 1.0], + [3.29675, 1.1169462, 1.0], + [-2.0880382, 1.0190989, 1.0], + [-0.5265214, 0.99782705, 1.0], + [0.0, 1.0, 1.0], + [4.2004547, 1.2332976, 1.0], + [2.4406395, 1.1688915, 1.0], + [-0.54659694, 1.020866, 1.0], + [-0.039971404, 0.99981636, 1.0], + [0.0, 1.0, 1.0], + [2.9126167, 1.2407047, 1.0], + [-0.152128, 1.1377726, 1.0], + [-2.4389071, 0.9923629, 1.0], + [-0.18998405, 0.99835, 1.0], + [0.0, 1.0, 1.0], + [0.832136, 1.2248999, 1.0], + [-4.439477, 1.0757953, 1.0], + [-6.5581284, 0.94083136, 1.0], + [-1.3619823, 0.98418033, 1.0], + [0.0, 1.0, 1.0], + [-0.8086253, 1.2119304, 1.0], + [-6.9719944, 1.0509604, 1.0], + [-8.899021, 0.9207292, 1.0], + [-2.5299528, 0.97328454, 1.0], + [0.0, 1.0, 1.0], + [-1.2520319, 1.1828909, 1.0], + [-7.6938405, 1.0309292, 1.0], + [-9.733912, 0.9186155, 1.0], + [-2.4881692, 0.97676903, 1.0], + [0.0, 1.0, 1.0], + [-0.6646547, 1.1841325, 1.0], + [-6.6125364, 1.0378011, 1.0], + [-7.26035, 0.942406, 1.0], + [-1.0572171, 0.99080205, 1.0], + [0.0, 1.0, 1.0], + [0.598152, 1.1774639, 1.0], + [-4.97536, 1.0513587, 1.0], + [-3.3090515, 0.9811577, 1.0], + [-0.20941074, 0.9982669, 1.0], + [0.0, 1.0, 1.0], + [0.010846901, 1.1766325, 1.0], + [-5.117737, 1.0593848, 1.0], + [-2.631352, 0.99419326, 1.0], + [-0.11850837, 0.9993512, 1.0], + [0.0, 1.0, 1.0], + [-1.5248753, 1.1829028, 1.0], + [-6.0299883, 1.0577121, 1.0], + [-2.215714, 0.99519426, 1.0], + [-0.067606665, 0.9995176, 1.0], + [0.0, 1.0, 1.0], + [-1.1450118, 1.1907917, 1.0], + [-5.886881, 1.0472964, 1.0], + [-2.5883603, 0.98812264, 1.0], + [-0.085475355, 0.9989431, 1.0], + [0.0, 1.0, 1.0], + [0.39547902, 1.2007595, 1.0], + [-4.610833, 1.0562892, 1.0], + [-1.7052215, 0.99749553, 1.0], + [-0.037703, 0.9996055, 1.0], + [0.0, 1.0, 1.0], + [-0.01975878, 1.2410866, 1.0], + [-1.2528956, 1.080659, 1.0], + [-0.68467104, 1.0020955, 1.0], + [-0.019976415, 0.99990904, 1.0], + [0.0, 1.0, 1.0], + [-0.2921033, 1.2522528, 1.0], + [0.5268386, 1.0997523, 1.0], + [0.03676964, 1.0044748, 1.0], + [-0.0015876454, 1.0000012, 1.0], + [0.0, 1.0, 1.0], + [-2.6588416, 1.1782904, 1.0], + [-0.8304472, 1.0511743, 1.0], + [-0.09593859, 1.0008658, 1.0], + [-0.0012349889, 0.9999909, 1.0], + [0.0, 1.0, 1.0], + [-5.5003, 1.1401259, 1.0], + [-2.4005525, 1.002213, 1.0], + [-0.7121907, 0.98226595, 1.0], + [-0.017461486, 0.999597, 1.0], + [0.0, 1.0, 1.0], + [-5.994106, 1.0949768, 1.0], + [-2.85081, 1.0083107, 1.0], + [-0.30721804, 0.9763625, 1.0], + [-0.0013189864, 0.9991703, 1.0], + [0.0, 1.0, 1.0], + [-5.196782, 1.1068348, 1.0], + [-3.2161317, 1.0495526, 1.0], + [-1.099006, 0.99147415, 1.0], + [-0.024919514, 0.9993193, 1.0], + [0.0, 1.0, 1.0], + [-4.84915, 1.05568, 1.0], + [-3.9007847, 1.03011, 1.0], + [-2.599225, 1.0099269, 1.0], + [-0.11461259, 0.99996513, 1.0], + [0.0, 1.0, 1.0], + [-4.721844, 1.055208, 1.0], + [-3.9854348, 1.0393229, 1.0], + [-2.5744975, 1.0209645, 1.0], + [-0.293057, 1.001174, 1.0], + [0.0, 1.0, 1.0], + [-4.059898, 1.0536826, 1.0], + [-2.5768554, 1.0555562, 1.0], + [-1.39879, 1.0136101, 1.0], + [-0.50610787, 0.9911003, 1.0], + [0.0, 1.0, 1.0], + [-2.9069445, 1.0556097, 1.0], + [-1.5781095, 1.0518695, 1.0], + [-0.8278498, 1.001524, 1.0], + [-0.32421526, 0.98277986, 1.0], + [0.0, 1.0, 1.0], + [-1.4363952, 1.0812076, 1.0], + [0.5487326, 1.0899016, 1.0], + [2.4110415, 1.0404066, 1.0], + [2.9115984, 1.0175672, 1.0], + [0.0, 1.0, 1.0], + [0.008678005, 1.1017222, 1.0], + [1.2356163, 1.1221837, 1.0], + [2.8750088, 1.0839373, 1.0], + [3.1348855, 1.032868, 1.0], + [0.0, 1.0, 1.0], + [0.88217217, 1.091444, 1.0], + [2.0927439, 1.1262348, 1.0], + [3.240285, 1.1331868, 1.0], + [2.67113, 1.0834583, 1.0], + [0.0, 1.0, 1.0], + [2.428248, 1.095952, 1.0], + [3.390544, 1.1449028, 1.0], + [3.5510561, 1.1565588, 1.0], + [2.31717, 1.0987086, 1.0], + [0.0, 1.0, 1.0], + [4.1580663, 1.0814137, 1.0], + [4.4028983, 1.1175768, 1.0], + [3.174666, 1.1427947, 1.0], + [2.0205822, 1.1122999, 1.0], + [0.0, 1.0, 1.0], + [6.2129626, 1.0714386, 1.0], + [6.590155, 1.0948668, 1.0], + [4.619345, 1.1303223, 1.0], + [1.1349491, 1.0543766, 1.0], + [0.0, 1.0, 1.0], + [6.7700667, 1.0417291, 1.0], + [6.674038, 1.0694815, 1.0], + [3.9405415, 1.1325624, 1.0], + [0.40215716, 1.0195748, 1.0], + [0.0, 1.0, 1.0], + [7.373421, 1.0177879, 1.0], + [5.4952292, 1.081058, 1.0], + [2.1721451, 1.1446264, 1.0], + [0.14383824, 1.0215358, 1.0], + [0.0, 1.0, 1.0], + [8.597993, 1.0280006, 1.0], + [4.9888263, 1.084475, 1.0], + [1.9800822, 1.1122874, 1.0], + [-0.23990147, 1.0166304, 1.0], + [0.0, 1.0, 1.0], + [9.2620325, 1.0467869, 1.0], + [6.4645605, 1.0722194, 1.0], + [2.8058643, 1.069191, 1.0], + [0.13071911, 1.005048, 1.0], + [0.0, 1.0, 1.0], + [9.7541, 1.079291, 1.0], + [7.8130198, 1.0582371, 1.0], + [3.0989928, 1.0383492, 1.0], + [0.14017454, 1.0023274, 1.0], + [0.0, 1.0, 1.0], + [9.04703, 1.1110121, 1.0], + [6.10701, 1.0926124, 1.0], + [2.378649, 1.0378062, 1.0], + [0.111506626, 1.001809, 1.0], + [0.0, 1.0, 1.0], + [9.323562, 1.1182009, 1.0], + [7.539196, 1.0773741, 1.0], + [2.3685758, 1.0214494, 1.0], + [0.07044682, 1.0008357, 1.0], + [0.0, 1.0, 1.0], + [8.691661, 1.143956, 1.0], + [7.088831, 1.0745311, 1.0], + [1.8496133, 1.0144777, 1.0], + [0.05676793, 1.0005342, 1.0], + [0.0, 1.0, 1.0], + [7.4111695, 1.2071966, 1.0], + [4.6049232, 1.1096628, 1.0], + [1.9098912, 1.0257343, 1.0], + [0.07917395, 1.0008571, 1.0], + [0.0, 1.0, 1.0], + [7.0088325, 1.2360815, 1.0], + [5.181472, 1.1226107, 1.0], + [2.5577261, 1.0353444, 1.0], + [0.112243205, 1.0013376, 1.0], + [0.0, 1.0, 1.0], + [8.824198, 1.2390603, 1.0], + [7.472616, 1.1490262, 1.0], + [3.6435897, 1.0531034, 1.0], + [0.21466114, 1.00276, 1.0], + [0.0, 1.0, 1.0], + [9.237296, 1.2194712, 1.0], + [7.4227743, 1.131169, 1.0], + [3.8712175, 1.049929, 1.0], + [0.27166268, 1.0031942, 1.0], + [0.0, 1.0, 1.0], + [8.786514, 1.1667371, 1.0], + [6.6639266, 1.1076087, 1.0], + [2.2446363, 1.0388349, 1.0], + [0.102016255, 1.0017875, 1.0], + [0.0, 1.0, 1.0], + [7.9329085, 1.1376165, 1.0], + [6.6409307, 1.1026622, 1.0], + [3.2540584, 1.0458134, 1.0], + [0.23406105, 1.0032705, 1.0], + [0.0, 1.0, 1.0], + [9.150625, 1.1396258, 1.0], + [6.7835217, 1.1053689, 1.0], + [3.3122694, 1.0424184, 1.0], + [0.22282204, 1.0027751, 1.0], + [0.0, 1.0, 1.0], + [9.179195, 1.1203728, 1.0], + [6.1122427, 1.0689247, 1.0], + [2.574998, 1.0149101, 1.0], + [0.17865337, 1.001089, 1.0], + [0.0, 1.0, 1.0], + [8.981582, 1.1198573, 1.0], + [6.162663, 1.0758042, 1.0], + [3.49672, 1.0282632, 1.0], + [0.5296274, 1.0024091, 1.0], + [0.0, 1.0, 1.0], + [7.104293, 1.1063842, 1.0], + [6.60601, 1.0895218, 1.0], + [6.026096, 1.0566804, 1.0], + [1.4251214, 1.0060695, 1.0], + [0.0, 1.0, 1.0], + [5.400258, 1.118055, 1.0], + [8.1627245, 1.1168847, 1.0], + [8.838523, 1.1060567, 1.0], + [3.2699623, 1.0280458, 1.0], + [0.0, 1.0, 1.0], + [4.0916386, 1.0858877, 1.0], + [7.020515, 1.0801034, 1.0], + [8.994645, 1.1054409, 1.0], + [6.3794594, 1.070257, 1.0], + [0.0, 1.0, 1.0], + [2.8969727, 1.0963963, 1.0], + [5.3678546, 1.111281, 1.0], + [7.492073, 1.145503, 1.0], + [6.806583, 1.107631, 1.0], + [0.0, 1.0, 1.0], + [11.097742, 0.7495534, 1.0], + [4.302149, 1.0255402, 1.0], + [4.3949976, 1.078089, 1.0], + [3.0446813, 1.0549035, 1.0], + [0.0, 1.0, 1.0], + [8.670632, 0.7986941, 1.0], + [4.084819, 1.0083629, 1.0], + [4.0668464, 1.0512977, 1.0], + [3.5106099, 1.0471594, 1.0], + [0.0, 1.0, 1.0], + [6.4860106, 0.8172128, 1.0], + [3.818937, 0.93780375, 1.0], + [3.2241101, 1.0080932, 1.0], + [2.3703701, 1.0238551, 1.0], + [0.0, 1.0, 1.0], + [4.833141, 0.8377232, 1.0], + [4.498794, 0.8643455, 1.0], + [3.6683908, 0.9057964, 1.0], + [1.7416582, 0.96007955, 1.0], + [0.0, 1.0, 1.0], + [4.6399913, 0.80761904, 1.0], + [4.839282, 0.8098061, 1.0], + [4.2472334, 0.8426727, 1.0], + [2.3996296, 0.87981254, 1.0], + [0.0, 1.0, 1.0], + [4.0833488, 0.78954774, 1.0], + [4.703831, 0.781375, 1.0], + [4.1265993, 0.8191039, 1.0], + [3.244208, 0.82794446, 1.0], + [0.0, 1.0, 1.0], + [2.2156487, 0.8334128, 1.0], + [2.5546548, 0.82133436, 1.0], + [2.4130013, 0.82352406, 1.0], + [2.1992364, 0.81915104, 1.0], + [0.0, 1.0, 1.0], + [1.719304, 0.8250702, 1.0], + [1.4982271, 0.8221971, 1.0], + [1.3897603, 0.840134, 1.0], + [1.7225292, 0.8336163, 1.0], + [0.0, 1.0, 1.0], + [1.0925283, 0.85481983, 1.0], + [0.7631879, 0.85975116, 1.0], + [0.027124116, 0.87992114, 1.0], + [0.60234094, 0.84217334, 1.0], + [0.0, 1.0, 1.0], + [1.077557, 0.872631, 1.0], + [1.0173641, 0.8593242, 1.0], + [0.46943918, 0.87838364, 1.0], + [2.6384852, 0.79771537, 1.0], + [0.0, 1.0, 1.0], + [0.7770302, 0.9353661, 1.0], + [1.0596069, 0.8860215, 1.0], + [1.2889328, 0.8582148, 1.0], + [4.9600816, 0.74249977, 1.0], + [0.0, 1.0, 1.0], + [0.34926006, 0.98829997, 1.0], + [0.18424937, 0.9600296, 1.0], + [1.1720486, 0.8844167, 1.0], + [2.579216, 0.7862993, 1.0], + [0.0, 1.0, 1.0], + [0.30250847, 0.9603346, 1.0], + [1.107201, 0.928601, 1.0], + [1.9961823, 0.8556583, 1.0], + [-0.3471245, 0.82866865, 1.0], + [0.0, 1.0, 1.0], + [-0.7629411, 1.0180708, 1.0], + [0.610268, 0.974038, 1.0], + [1.429175, 0.8520721, 1.0], + [0.48019677, 0.8095613, 1.0], + [0.0, 1.0, 1.0], + [-0.3008022, 0.9407405, 1.0], + [1.6219236, 0.9161009, 1.0], + [0.49270624, 0.8484004, 1.0], + [-0.12723985, 0.791222, 1.0], + [0.0, 1.0, 1.0], + [0.25840467, 0.85001826, 1.0], + [1.2374158, 0.87433594, 1.0], + [-0.47591653, 0.8426007, 1.0], + [-0.9091751, 0.79707766, 1.0], + [0.0, 1.0, 1.0], + [1.1872596, 0.8374762, 1.0], + [1.1351508, 0.8393883, 1.0], + [-0.09448363, 0.82199603, 1.0], + [-0.22021097, 0.80633336, 1.0], + [0.0, 1.0, 1.0], + [0.99161613, 0.82922834, 1.0], + [0.5485594, 0.83473396, 1.0], + [0.39220774, 0.826323, 1.0], + [0.58646864, 0.81625366, 1.0], + [0.0, 1.0, 1.0], + [0.25968486, 0.823715, 1.0], + [-0.11242166, 0.8438889, 1.0], + [0.41922796, 0.8506193, 1.0], + [1.028925, 0.8475653, 1.0], + [0.0, 1.0, 1.0], + [1.0410757, 0.82169753, 1.0], + [1.1827178, 0.83744586, 1.0], + [1.5406011, 0.8407932, 1.0], + [1.5877901, 0.88981646, 1.0], + [0.0, 1.0, 1.0], + [1.7911158, 0.83056414, 1.0], + [4.6160154, 0.8464025, 1.0], + [5.6431475, 0.8491971, 1.0], + [2.9428084, 0.91466653, 1.0], + [0.0, 1.0, 1.0], + [2.4177535, 0.8561214, 1.0], + [6.020544, 0.8638199, 1.0], + [6.9461846, 0.8605439, 1.0], + [3.500521, 0.92217606, 1.0], + [0.0, 1.0, 1.0], + [2.851688, 0.89191425, 1.0], + [1.421044, 0.9390341, 1.0], + [-1.1085083, 0.9700407, 1.0], + [-0.36786354, 0.9936673, 1.0], + [0.0, 1.0, 1.0], + [2.3683667, 0.9257322, 1.0], + [-0.26451036, 0.95317423, 1.0], + [-2.130682, 0.9368306, 1.0], + [-0.4093729, 0.986178, 1.0], + [0.0, 1.0, 1.0], + [1.5891942, 0.99163103, 1.0], + [0.045109738, 0.966062, 1.0], + [-0.53449297, 0.92987776, 1.0], + [-0.033470135, 0.9932046, 1.0], + [0.0, 1.0, 1.0], + [-0.27134877, 0.99536645, 1.0], + [0.0898188, 0.98402214, 1.0], + [0.27262244, 0.96843773, 1.0], + [0.009789813, 0.9986736, 1.0], + [0.0, 1.0, 1.0], + [-0.7730278, 1.0203122, 1.0], + [-2.9028432, 1.0235066, 1.0], + [-1.0447694, 0.9868708, 1.0], + [-0.04624136, 0.99908286, 1.0], + [0.0, 1.0, 1.0], + [-1.3631408, 1.0156548, 1.0], + [-3.9609292, 1.0409077, 1.0], + [-1.4876856, 0.9928084, 1.0], + [-0.095956564, 0.998979, 1.0], + [0.0, 1.0, 1.0], + [-1.7949396, 0.9771055, 1.0], + [-3.3644142, 1.0089597, 1.0], + [-0.6663962, 0.98575324, 1.0], + [-0.060724735, 0.9990037, 1.0], + [0.0, 1.0, 1.0], + [-2.6404126, 0.9683573, 1.0], + [-3.7188525, 0.9884891, 1.0], + [-0.33517227, 0.9891578, 1.0], + [-0.01776004, 0.99959093, 1.0], + [0.0, 1.0, 1.0], + [-1.5392375, 0.9850047, 1.0], + [-1.9149097, 0.9972289, 1.0], + [0.076658234, 0.99591476, 1.0], + [0.0015469929, 0.99989164, 1.0], + [0.0, 1.0, 1.0], + [-1.5000389, 0.9821583, 1.0], + [-2.8312242, 0.9990609, 1.0], + [-0.28693938, 0.9993521, 1.0], + [-0.003116089, 0.9999806, 1.0], + [0.0, 1.0, 1.0], + [-2.261437, 0.9956475, 1.0], + [-4.1936493, 0.99516, 1.0], + [-0.5251263, 0.99886537, 1.0], + [-0.0044811945, 0.99998087, 1.0], + [0.0, 1.0, 1.0], + [-2.1491625, 1.0218415, 1.0], + [-4.5739403, 1.0102247, 1.0], + [-0.68651015, 0.99985665, 1.0], + [-0.0075566974, 0.99995035, 1.0], + [0.0, 1.0, 1.0], + [-1.2349107, 0.9898649, 1.0], + [-1.9306059, 1.0036258, 1.0], + [-0.31590265, 0.99970144, 1.0], + [-0.0033171063, 0.9999775, 1.0], + [0.0, 1.0, 1.0], + [-1.0307009, 0.99630994, 1.0], + [0.30616513, 1.0119231, 1.0], + [0.06396701, 1.0005364, 1.0], + [0.00027660295, 0.9999968, 1.0], + [0.0, 1.0, 1.0], + [-2.346574, 0.9880379, 1.0], + [-0.8791935, 1.0093088, 1.0], + [-0.016242307, 1.0002874, 1.0], + [0.00025250288, 0.99999845, 1.0], + [0.0, 1.0, 1.0], + [-3.0779517, 0.97170013, 1.0], + [-1.4663464, 0.99664164, 1.0], + [-0.10389895, 0.9996869, 1.0], + [-0.00044551404, 0.9999951, 1.0], + [0.0, 1.0, 1.0], + [-4.567538, 0.9528182, 1.0], + [-1.9175193, 0.96272826, 1.0], + [-0.24028678, 0.988517, 1.0], + [-0.0031621323, 0.9997933, 1.0], + [0.0, 1.0, 1.0], + [-5.301078, 0.9462786, 1.0], + [-2.0612185, 0.9623441, 1.0], + [-0.22937998, 0.98045844, 1.0], + [-0.0035447287, 0.99954635, 1.0], + [0.0, 1.0, 1.0], + [-5.17369, 0.9347734, 1.0], + [-2.6256938, 0.9702652, 1.0], + [-0.9779252, 0.98185503, 1.0], + [-0.025275169, 0.999532, 1.0], + [0.0, 1.0, 1.0], + [-5.6190476, 0.87789106, 1.0], + [-4.1578727, 0.9216868, 1.0], + [-2.5072808, 0.9612179, 1.0], + [-0.089652225, 0.9986668, 1.0], + [0.0, 1.0, 1.0], + [-6.76994, 0.87418616, 1.0], + [-6.514886, 0.907811, 1.0], + [-4.9078856, 0.9322168, 1.0], + [-0.41067597, 0.99400187, 1.0], + [0.0, 1.0, 1.0], + [-6.1820183, 0.9030464, 1.0], + [-4.7838874, 0.9592428, 1.0], + [-3.901864, 0.9493777, 1.0], + [-2.8153791, 0.9527283, 1.0], + [0.0, 1.0, 1.0], + [-5.788914, 0.91208506, 1.0], + [-3.994215, 0.97003484, 1.0], + [-3.385323, 0.9577372, 1.0], + [-2.9413586, 0.9510517, 1.0], + [0.0, 1.0, 1.0], + [-5.824406, 0.83820003, 1.0], + [-3.1810389, 0.9660887, 1.0], + [-1.5774505, 0.9790124, 1.0], + [-0.8217753, 0.9740097, 1.0], + [0.0, 1.0, 1.0], + [-4.3065467, 0.80589384, 1.0], + [-2.6072268, 0.93019086, 1.0], + [-0.21090473, 0.9885001, 1.0], + [0.43375567, 0.9852358, 1.0], + [0.0, 1.0, 1.0], + [-2.482041, 0.80521226, 1.0], + [-1.9464252, 0.89081764, 1.0], + [-1.3067281, 0.97063005, 1.0], + [-0.08558672, 1.0085713, 1.0], + [0.0, 1.0, 1.0], + [0.005971689, 0.79016274, 1.0], + [0.35732928, 0.85779244, 1.0], + [0.1754206, 0.99188244, 1.0], + [0.22379632, 1.037684, 1.0], + [0.0, 1.0, 1.0], + [2.7745373, 0.8123556, 1.0], + [2.660167, 0.8928497, 1.0], + [1.9056137, 1.0349722, 1.0], + [1.3743136, 1.0741935, 1.0], + [0.0, 1.0, 1.0], + [5.84149, 0.94729656, 1.0], + [5.9234858, 0.99772394, 1.0], + [3.5638256, 1.0254989, 1.0], + [0.789219, 1.0284225, 1.0], + [0.0, 1.0, 1.0], + [6.216669, 0.94323486, 1.0], + [6.1197004, 0.97384584, 1.0], + [3.111261, 1.0015063, 1.0], + [0.13389735, 1.0010753, 1.0], + [0.0, 1.0, 1.0], + [6.8003964, 0.8915141, 1.0], + [6.054007, 0.933874, 1.0], + [2.107739, 1.0027483, 1.0], + [0.0493525, 1.0011154, 1.0], + [0.0, 1.0, 1.0], + [7.9880567, 0.84407914, 1.0], + [5.6231203, 0.93609124, 1.0], + [2.2157097, 1.0120378, 1.0], + [0.032817453, 1.0015986, 1.0], + [0.0, 1.0, 1.0], + [8.595032, 0.8414821, 1.0], + [5.738505, 0.9392406, 1.0], + [1.8269083, 1.0084609, 1.0], + [0.04897936, 1.0008367, 1.0], + [0.0, 1.0, 1.0], + [8.220856, 0.87541443, 1.0], + [5.0150123, 0.95354825, 1.0], + [1.1460975, 0.9991748, 1.0], + [0.023244264, 1.0001849, 1.0], + [0.0, 1.0, 1.0], + [6.352977, 0.90786576, 1.0], + [4.568452, 0.9657601, 1.0], + [1.6529517, 1.000971, 1.0], + [0.03857504, 1.0002371, 1.0], + [0.0, 1.0, 1.0], + [4.984532, 0.9082033, 1.0], + [4.602232, 0.9495377, 1.0], + [1.6734874, 0.9877272, 1.0], + [0.03744144, 0.9998395, 1.0], + [0.0, 1.0, 1.0], + [5.9091544, 0.93606, 1.0], + [5.298362, 0.9841254, 1.0], + [1.5838195, 1.0008063, 1.0], + [0.046534117, 1.0002071, 1.0], + [0.0, 1.0, 1.0], + [5.066354, 0.97590166, 1.0], + [4.5295296, 1.0173208, 1.0], + [1.7539304, 1.013229, 1.0], + [0.053133972, 1.0004649, 1.0], + [0.0, 1.0, 1.0], + [3.9509256, 0.96985763, 1.0], + [2.8036354, 1.0145437, 1.0], + [1.5774267, 1.01402, 1.0], + [0.066340216, 1.0006825, 1.0], + [0.0, 1.0, 1.0], + [5.067796, 0.9293189, 1.0], + [4.159626, 1.0254444, 1.0], + [2.7054405, 1.0338923, 1.0], + [0.16508698, 1.0020102, 1.0], + [0.0, 1.0, 1.0], + [6.1854787, 0.88944936, 1.0], + [4.127846, 0.9825014, 1.0], + [2.468719, 1.0214276, 1.0], + [0.17890404, 1.0017699, 1.0], + [0.0, 1.0, 1.0], + [5.5697684, 0.868272, 1.0], + [3.8859425, 0.95566565, 1.0], + [1.9449633, 1.0140244, 1.0], + [0.15235585, 1.0016147, 1.0], + [0.0, 1.0, 1.0], + [4.9004993, 0.8488444, 1.0], + [3.3493254, 0.9401458, 1.0], + [1.7450691, 1.0157793, 1.0], + [0.2219146, 1.0026209, 1.0], + [0.0, 1.0, 1.0], + [5.1511345, 0.8408119, 1.0], + [2.344393, 0.9062895, 1.0], + [-0.006539325, 0.989216, 1.0], + [-0.08388412, 0.9982721, 1.0], + [0.0, 1.0, 1.0], + [5.9436674, 0.83059716, 1.0], + [2.7960584, 0.8703427, 1.0], + [-0.36575812, 0.97218806, 1.0], + [-0.23437832, 0.9956032, 1.0], + [0.0, 1.0, 1.0], + [6.1871066, 0.7945113, 1.0], + [3.3608866, 0.8636077, 1.0], + [0.7976455, 0.97853297, 1.0], + [0.016725179, 0.99557835, 1.0], + [0.0, 1.0, 1.0], + [6.6724114, 0.8086163, 1.0], + [4.0700526, 0.9031084, 1.0], + [2.1568875, 0.99063873, 1.0], + [0.28391585, 0.99492174, 1.0], + [0.0, 1.0, 1.0], + [7.5037627, 0.8231193, 1.0], + [5.7297187, 0.9803322, 1.0], + [4.538334, 1.0247983, 1.0], + [0.78753865, 0.9985357, 1.0], + [0.0, 1.0, 1.0], + [10.182478, 0.7484477, 1.0], + [4.6068373, 0.93632764, 1.0], + [5.901852, 0.9994183, 1.0], + [1.9283233, 1.0053344, 1.0], + [0.0, 1.0, 1.0], + [11.154881, 0.70661646, 1.0], + [2.7159872, 0.93115985, 1.0], + [4.0488343, 1.0193834, 1.0], + [1.9382095, 1.0209278, 1.0], + [0.0, 1.0, 1.0], + [21.429089, 0.534035, 1.0], + [10.9904585, 0.6681652, 1.0], + [0.0045763263, 0.9355186, 1.0], + [0.13676341, 0.9799069, 1.0], + [0.0, 1.0, 1.0], + [19.529167, 0.5656457, 1.0], + [9.061795, 0.7158103, 1.0], + [1.5038702, 0.90302753, 1.0], + [0.78457785, 0.9086724, 1.0], + [0.0, 1.0, 1.0], + [15.508226, 0.5985953, 1.0], + [7.4871893, 0.7188201, 1.0], + [2.3094695, 0.89489657, 1.0], + [0.73899657, 0.9671641, 1.0], + [0.0, 1.0, 1.0], + [10.39154, 0.65087306, 1.0], + [6.622824, 0.6944927, 1.0], + [5.501718, 0.7407023, 1.0], + [1.7589878, 0.92959625, 1.0], + [0.0, 1.0, 1.0], + [8.076021, 0.62203556, 1.0], + [7.467209, 0.6181576, 1.0], + [7.245981, 0.6409904, 1.0], + [5.4263334, 0.7738036, 1.0], + [0.0, 1.0, 1.0], + [8.469799, 0.5848823, 1.0], + [8.423419, 0.5817134, 1.0], + [8.497577, 0.60529923, 1.0], + [9.111917, 0.69250333, 1.0], + [0.0, 1.0, 1.0], + [7.762021, 0.6242976, 1.0], + [7.8041244, 0.61129224, 1.0], + [8.319156, 0.64303446, 1.0], + [9.280437, 0.71576697, 1.0], + [0.0, 1.0, 1.0], + [5.9923735, 0.63731086, 1.0], + [4.8158092, 0.65566516, 1.0], + [6.311018, 0.6723989, 1.0], + [8.32342, 0.7326516, 1.0], + [0.0, 1.0, 1.0], + [6.2170925, 0.60791266, 1.0], + [5.0867386, 0.6423507, 1.0], + [6.415117, 0.66422945, 1.0], + [8.737183, 0.70679927, 1.0], + [0.0, 1.0, 1.0], + [5.2006907, 0.60027266, 1.0], + [4.8628798, 0.61094993, 1.0], + [6.024442, 0.6481432, 1.0], + [9.87192, 0.66732514, 1.0], + [0.0, 1.0, 1.0], + [4.6975617, 0.6112319, 1.0], + [4.514887, 0.6216314, 1.0], + [6.3849893, 0.63863033, 1.0], + [10.629338, 0.64065015, 1.0], + [0.0, 1.0, 1.0], + [4.5348377, 0.62149215, 1.0], + [3.3658662, 0.67045367, 1.0], + [5.1989746, 0.71183586, 1.0], + [8.826136, 0.6767556, 1.0], + [0.0, 1.0, 1.0], + [3.2873642, 0.6042733, 1.0], + [2.2218688, 0.65636855, 1.0], + [0.86957604, 0.82655025, 1.0], + [0.053957075, 0.88620996, 1.0], + [0.0, 1.0, 1.0], + [2.4001925, 0.6340274, 1.0], + [1.1141202, 0.70201296, 1.0], + [0.50959253, 0.8092494, 1.0], + [0.2311227, 0.8478709, 1.0], + [0.0, 1.0, 1.0], + [1.2452888, 0.66887766, 1.0], + [-0.07834225, 0.74380815, 1.0], + [-0.13215514, 0.7847734, 1.0], + [-0.38037032, 0.7592258, 1.0], + [0.0, 1.0, 1.0], + [-1.1262196, 0.6688091, 1.0], + [-1.8940061, 0.6305882, 1.0], + [-1.4030197, 0.7027344, 1.0], + [-1.2132488, 0.7527478, 1.0], + [0.0, 1.0, 1.0], + [-1.7841728, 0.6271489, 1.0], + [-2.0797975, 0.61307365, 1.0], + [-1.7369123, 0.6998982, 1.0], + [-1.4816345, 0.78196275, 1.0], + [0.0, 1.0, 1.0], + [-1.5889271, 0.6240376, 1.0], + [-1.8163804, 0.6369125, 1.0], + [-0.99799794, 0.7531041, 1.0], + [-0.5732026, 0.82648337, 1.0], + [0.0, 1.0, 1.0], + [-2.0947924, 0.62250054, 1.0], + [-2.1894937, 0.6211645, 1.0], + [-1.2518405, 0.701402, 1.0], + [-0.14743061, 0.8963739, 1.0], + [0.0, 1.0, 1.0], + [-1.3260301, 0.622353, 1.0], + [-1.4357653, 0.6204895, 1.0], + [-0.9074722, 0.7065318, 1.0], + [-0.00092270726, 0.94407517, 1.0], + [0.0, 1.0, 1.0], + [0.53253675, 0.609965, 1.0], + [0.7909907, 0.63592654, 1.0], + [0.8281013, 0.861355, 1.0], + [0.09484549, 0.99166083, 1.0], + [0.0, 1.0, 1.0], + [2.1939044, 0.6141987, 1.0], + [2.304398, 0.68116254, 1.0], + [1.0291927, 0.94084936, 1.0], + [0.07020744, 0.99777913, 1.0], + [0.0, 1.0, 1.0], + [2.544789, 0.6641931, 1.0], + [1.7962058, 0.7804687, 1.0], + [0.024910655, 0.9779748, 1.0], + [-0.006917878, 0.9988935, 1.0], + [0.0, 1.0, 1.0], + [2.512072, 0.6963922, 1.0], + [1.0768867, 0.8578466, 1.0], + [-0.15686627, 0.95935565, 1.0], + [-0.018332472, 0.9965962, 1.0], + [0.0, 1.0, 1.0], + [2.2941554, 0.7241867, 1.0], + [0.49197897, 0.8924251, 1.0], + [-0.07057831, 0.97556025, 1.0], + [0.0007015772, 0.9984997, 1.0], + [0.0, 1.0, 1.0], + [-0.14841639, 0.75324446, 1.0], + [0.3125728, 0.9184302, 1.0], + [0.12940219, 0.99002546, 1.0], + [0.004882609, 0.9996811, 1.0], + [0.0, 1.0, 1.0], + [1.0759145, 0.8147672, 1.0], + [0.36428088, 0.95350146, 1.0], + [0.044762604, 0.9945495, 1.0], + [0.0020008544, 0.99985397, 1.0], + [0.0, 1.0, 1.0], + [1.1844163, 0.82202315, 1.0], + [0.079763375, 0.9658192, 1.0], + [-0.040133536, 0.99962455, 1.0], + [-0.0008112378, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [1.3717666, 0.79799265, 1.0], + [0.041872248, 0.9601765, 1.0], + [-0.031498604, 0.9993519, 1.0], + [-0.00014435564, 0.99999434, 1.0], + [0.0, 1.0, 1.0], + [1.4442534, 0.8161425, 1.0], + [-0.063920654, 0.9636011, 1.0], + [-0.05635313, 0.9993309, 1.0], + [-0.00048342353, 0.99999565, 1.0], + [0.0, 1.0, 1.0], + [0.52015144, 0.8598735, 1.0], + [0.19060682, 0.97779083, 1.0], + [0.0042832624, 0.9997414, 1.0], + [4.660216e-05, 0.999999, 1.0], + [0.0, 1.0, 1.0], + [-0.7497536, 0.84093064, 1.0], + [-0.81031346, 0.97599655, 1.0], + [-0.09859322, 0.99943244, 1.0], + [-0.0006943415, 0.9999969, 1.0], + [0.0, 1.0, 1.0], + [-1.3677365, 0.8722283, 1.0], + [-1.8130565, 0.97921914, 1.0], + [-0.21724299, 0.9991554, 1.0], + [-0.0015388737, 0.99999464, 1.0], + [0.0, 1.0, 1.0], + [-0.91486025, 0.8624227, 1.0], + [-1.8185471, 0.98354757, 1.0], + [-0.18334843, 1.00006, 1.0], + [-0.0010572135, 1.0000012, 1.0], + [0.0, 1.0, 1.0], + [-0.7346696, 0.8424903, 1.0], + [-0.63329214, 0.9792088, 1.0], + [-0.059044026, 0.9998297, 1.0], + [-0.00033109298, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-1.6148448, 0.8629954, 1.0], + [-0.1529285, 0.9853164, 1.0], + [-0.0012836644, 0.99992263, 1.0], + [2.836192e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-2.1700978, 0.8661239, 1.0], + [-0.63642955, 0.98434937, 1.0], + [-0.031005092, 0.99981445, 1.0], + [-0.000120288605, 0.99999934, 1.0], + [0.0, 1.0, 1.0], + [-2.4762814, 0.86192167, 1.0], + [-0.56582534, 0.98256135, 1.0], + [-0.020217514, 0.9998631, 1.0], + [-7.2207775e-05, 0.9999997, 1.0], + [0.0, 1.0, 1.0], + [-3.8507402, 0.82345366, 1.0], + [-0.7367012, 0.96947515, 1.0], + [-0.014731503, 0.9995469, 1.0], + [-6.203018e-05, 0.9999977, 1.0], + [0.0, 1.0, 1.0], + [-4.3435655, 0.7789662, 1.0], + [-1.2070022, 0.9441542, 1.0], + [-0.06403829, 0.99689096, 1.0], + [-0.0005338597, 0.999972, 1.0], + [0.0, 1.0, 1.0], + [-7.598504, 0.7135656, 1.0], + [-2.8674366, 0.8796157, 1.0], + [-0.2061764, 0.98925316, 1.0], + [-0.0019789634, 0.9999003, 1.0], + [0.0, 1.0, 1.0], + [-8.5659275, 0.68000054, 1.0], + [-6.2886615, 0.7959308, 1.0], + [-1.6636302, 0.9520329, 1.0], + [-0.02707145, 0.99932, 1.0], + [0.0, 1.0, 1.0], + [-9.858409, 0.65844935, 1.0], + [-10.290696, 0.7347493, 1.0], + [-7.691491, 0.82455605, 1.0], + [-0.43717942, 0.9903527, 1.0], + [0.0, 1.0, 1.0], + [-9.475192, 0.660726, 1.0], + [-8.357337, 0.8211995, 1.0], + [-6.577057, 0.9004506, 1.0], + [-2.6555653, 0.960382, 1.0], + [0.0, 1.0, 1.0], + [-9.107314, 0.6639968, 1.0], + [-6.700647, 0.8769558, 1.0], + [-5.7258368, 0.9253376, 1.0], + [-4.3847528, 0.93834984, 1.0], + [0.0, 1.0, 1.0], + [-8.857171, 0.6214388, 1.0], + [-7.408514, 0.77258706, 1.0], + [-5.1308303, 0.92324936, 1.0], + [-3.1422675, 0.95364594, 1.0], + [0.0, 1.0, 1.0], + [-7.9207935, 0.5845634, 1.0], + [-7.1896048, 0.6665354, 1.0], + [-4.057381, 0.85848784, 1.0], + [-0.73692507, 0.97766274, 1.0], + [0.0, 1.0, 1.0], + [-6.263444, 0.5832066, 1.0], + [-5.9315505, 0.6382356, 1.0], + [-6.1455026, 0.72098804, 1.0], + [-1.0433422, 0.96975046, 1.0], + [0.0, 1.0, 1.0], + [-2.4575808, 0.57827437, 1.0], + [-2.964139, 0.62386805, 1.0], + [-4.5475383, 0.7487994, 1.0], + [-1.4768721, 0.9732885, 1.0], + [0.0, 1.0, 1.0], + [1.157196, 0.57085544, 1.0], + [-0.23392056, 0.64966595, 1.0], + [-1.4028531, 0.88008684, 1.0], + [-1.9659572, 0.9766486, 1.0], + [0.0, 1.0, 1.0], + [4.5584664, 0.6198235, 1.0], + [4.1219063, 0.74054, 1.0], + [0.9924724, 0.93495196, 1.0], + [-0.7218012, 0.988608, 1.0], + [0.0, 1.0, 1.0], + [3.719361, 0.6867312, 1.0], + [3.9891121, 0.7887134, 1.0], + [1.6480545, 0.94551146, 1.0], + [0.059591822, 0.99834126, 1.0], + [0.0, 1.0, 1.0], + [4.201495, 0.7431256, 1.0], + [4.526226, 0.8292865, 1.0], + [1.1760861, 0.9666664, 1.0], + [0.023769544, 0.9994029, 1.0], + [0.0, 1.0, 1.0], + [4.170543, 0.76272196, 1.0], + [2.6068974, 0.8673706, 1.0], + [0.39172325, 0.9858963, 1.0], + [0.004990869, 0.9999019, 1.0], + [0.0, 1.0, 1.0], + [4.301262, 0.77241415, 1.0], + [2.272072, 0.8702671, 1.0], + [0.20433316, 0.98867095, 1.0], + [0.0015561674, 0.9999392, 1.0], + [0.0, 1.0, 1.0], + [1.9231374, 0.8245692, 1.0], + [1.1306319, 0.9499702, 1.0], + [0.12337404, 0.9968917, 1.0], + [0.0012145389, 0.9999798, 1.0], + [0.0, 1.0, 1.0], + [0.7749719, 0.84335756, 1.0], + [0.5761641, 0.95042235, 1.0], + [0.15160465, 0.9963909, 1.0], + [0.0022156653, 0.9999764, 1.0], + [0.0, 1.0, 1.0], + [0.24714066, 0.83536404, 1.0], + [0.69087815, 0.93358576, 1.0], + [0.13375169, 0.9901696, 1.0], + [0.00097174913, 0.9998683, 1.0], + [0.0, 1.0, 1.0], + [0.9913264, 0.84575355, 1.0], + [0.6101601, 0.9683786, 1.0], + [0.03825838, 0.9969422, 1.0], + [-0.00015123004, 0.9999521, 1.0], + [0.0, 1.0, 1.0], + [-0.36478883, 0.8678444, 1.0], + [0.0008414984, 0.9753911, 1.0], + [0.040139902, 0.9994379, 1.0], + [0.0008033622, 0.999998, 1.0], + [0.0, 1.0, 1.0], + [1.5517159, 0.860102, 1.0], + [-0.14287986, 0.9731134, 1.0], + [-0.0241953, 0.9983607, 1.0], + [0.00026539815, 0.9999833, 1.0], + [0.0, 1.0, 1.0], + [1.771025, 0.8242807, 1.0], + [0.39131552, 0.9689884, 1.0], + [0.10286286, 1.0004354, 1.0], + [0.0041896314, 1.0000376, 1.0], + [0.0, 1.0, 1.0], + [3.0717576, 0.7639471, 1.0], + [1.06725, 0.9274218, 1.0], + [0.16506667, 0.99773335, 1.0], + [0.0063294014, 0.99998826, 1.0], + [0.0, 1.0, 1.0], + [2.2291703, 0.7308612, 1.0], + [0.49462095, 0.8725961, 1.0], + [-0.10180267, 0.97933596, 1.0], + [-0.0027677065, 0.99942666, 1.0], + [0.0, 1.0, 1.0], + [1.6348059, 0.6752963, 1.0], + [-0.1813347, 0.81271625, 1.0], + [-0.452213, 0.9789637, 1.0], + [-0.02926826, 0.99930775, 1.0], + [0.0, 1.0, 1.0], + [0.977978, 0.63618356, 1.0], + [-0.62426865, 0.7659978, 1.0], + [-1.2976239, 0.96756303, 1.0], + [-0.16725117, 0.9974616, 1.0], + [0.0, 1.0, 1.0], + [2.5557356, 0.63749415, 1.0], + [-0.46793, 0.74725634, 1.0], + [-1.4595929, 0.9653957, 1.0], + [-0.23743904, 0.99673766, 1.0], + [0.0, 1.0, 1.0], + [6.1136274, 0.6158691, 1.0], + [1.4451386, 0.7140542, 1.0], + [-0.7944747, 0.9716655, 1.0], + [-0.17677365, 0.9974942, 1.0], + [0.0, 1.0, 1.0], + [12.495926, 0.6081697, 1.0], + [2.6334763, 0.75307316, 1.0], + [-0.12727743, 0.9822432, 1.0], + [-0.019888313, 0.9994333, 1.0], + [0.0, 1.0, 1.0], + [20.359663, 0.5505554, 1.0], + [5.360571, 0.7478232, 1.0], + [-0.15579183, 0.9834688, 1.0], + [0.0031478892, 0.9996698, 1.0], + [0.0, 1.0, 1.0], + [22.983652, 0.51872075, 1.0], + [11.201727, 0.65178657, 1.0], + [0.02786072, 0.96850365, 1.0], + [0.050246127, 0.9990089, 1.0], + [0.0, 1.0, 1.0], + [22.102345, 0.5159302, 1.0], + [13.516024, 0.6239164, 1.0], + [-0.2264105, 0.9579077, 1.0], + [0.011741261, 0.9989512, 1.0], + [0.0, 1.0, 1.0], + [16.458723, 0.6381099, 1.0], + [2.070874, 0.8461226, 1.0], + [-0.38940287, 0.94793105, 1.0], + [-0.004449404, 0.9940882, 1.0], + [0.0, 1.0, 1.0], + [14.319419, 0.6865272, 1.0], + [2.107183, 0.90325254, 1.0], + [0.08382885, 0.95650256, 1.0], + [0.08427957, 0.95735323, 1.0], + [0.0, 1.0, 1.0], + [11.907165, 0.7272812, 1.0], + [2.3358235, 0.9138314, 1.0], + [0.061927434, 0.9853146, 1.0], + [0.02880717, 0.98940504, 1.0], + [0.0, 1.0, 1.0], + [9.328565, 0.7501317, 1.0], + [3.736033, 0.8165723, 1.0], + [0.886498, 0.9493906, 1.0], + [0.53428864, 0.9778165, 1.0], + [0.0, 1.0, 1.0], + [7.8924174, 0.6558145, 1.0], + [8.357237, 0.58703214, 1.0], + [5.640144, 0.71630454, 1.0], + [2.3299222, 0.9120247, 1.0], + [0.0, 1.0, 1.0], + [8.797466, 0.5970874, 1.0], + [10.189405, 0.5419211, 1.0], + [9.585496, 0.602155, 1.0], + [8.76234, 0.7506968, 1.0], + [0.0, 1.0, 1.0], + [5.77069, 0.73146415, 1.0], + [8.458371, 0.6105788, 1.0], + [9.997233, 0.66665345, 1.0], + [14.185366, 0.72296506, 1.0], + [0.0, 1.0, 1.0], + [4.832553, 0.7476512, 1.0], + [6.631264, 0.64727205, 1.0], + [9.165473, 0.66433716, 1.0], + [12.874936, 0.71633804, 1.0], + [0.0, 1.0, 1.0], + [4.3384695, 0.7561362, 1.0], + [5.5324025, 0.6862774, 1.0], + [6.1772375, 0.7093537, 1.0], + [5.11874, 0.82211673, 1.0], + [0.0, 1.0, 1.0], + [4.3927083, 0.68014836, 1.0], + [5.3236537, 0.617722, 1.0], + [5.153285, 0.7291801, 1.0], + [6.883996, 0.7625799, 1.0], + [0.0, 1.0, 1.0], + [3.9320729, 0.6744753, 1.0], + [4.8258367, 0.61940265, 1.0], + [5.4672303, 0.7232466, 1.0], + [8.657131, 0.7020352, 1.0], + [0.0, 1.0, 1.0], + [1.7061602, 0.8554576, 1.0], + [1.1815847, 0.8894174, 1.0], + [1.0903149, 0.93655825, 1.0], + [2.8275063, 0.8868961, 1.0], + [0.0, 1.0, 1.0], + [0.90848404, 0.85196733, 1.0], + [0.4342817, 0.9318938, 1.0], + [0.04572374, 0.9942397, 1.0], + [0.0133670345, 0.9983886, 1.0], + [0.0, 1.0, 1.0], + [0.4749884, 0.8968156, 1.0], + [0.13983242, 0.95840764, 1.0], + [0.012114237, 0.9945305, 1.0], + [0.004142619, 0.9975343, 1.0], + [0.0, 1.0, 1.0], + [0.2421307, 0.94426733, 1.0], + [0.025308255, 0.97187287, 1.0], + [-0.013660883, 0.9893051, 1.0], + [-0.014684059, 0.9894599, 1.0], + [0.0, 1.0, 1.0], + [-0.15639894, 0.9373672, 1.0], + [-0.3664362, 0.8935984, 1.0], + [-0.15253535, 0.9533504, 1.0], + [-0.039207056, 0.98815924, 1.0], + [0.0, 1.0, 1.0], + [-0.43275413, 0.8943086, 1.0], + [-0.70259815, 0.840497, 1.0], + [-0.252648, 0.937427, 1.0], + [-0.03330618, 0.9936832, 1.0], + [0.0, 1.0, 1.0], + [0.06659569, 0.87807643, 1.0], + [-0.1700009, 0.8497151, 1.0], + [-0.12636127, 0.963959, 1.0], + [-0.013172978, 0.9975136, 1.0], + [0.0, 1.0, 1.0], + [-0.64482135, 0.87148684, 1.0], + [-0.74943525, 0.83117217, 1.0], + [-0.27330378, 0.93200976, 1.0], + [-0.021869717, 0.9946897, 1.0], + [0.0, 1.0, 1.0], + [-1.0401838, 0.8728109, 1.0], + [-0.73823136, 0.8744596, 1.0], + [-0.18287456, 0.95961267, 1.0], + [-0.009951859, 0.9977242, 1.0], + [0.0, 1.0, 1.0], + [-0.22627981, 0.88137406, 1.0], + [0.007839352, 0.9245987, 1.0], + [0.0014544588, 0.99349904, 1.0], + [-2.9291497e-05, 0.99989325, 1.0], + [0.0, 1.0, 1.0], + [-0.18447767, 0.8809199, 1.0], + [0.008496483, 0.9478462, 1.0], + [0.0067973994, 0.9979893, 1.0], + [5.0736588e-05, 0.9999927, 1.0], + [0.0, 1.0, 1.0], + [0.29172453, 0.92011845, 1.0], + [0.15676522, 0.9734236, 1.0], + [0.004252948, 0.99931306, 1.0], + [-1.0232604e-05, 0.9999954, 1.0], + [0.0, 1.0, 1.0], + [0.17869076, 0.9557188, 1.0], + [0.08755076, 0.99127334, 1.0], + [0.0010010353, 0.99973154, 1.0], + [-5.539131e-05, 0.9999892, 1.0], + [0.0, 1.0, 1.0], + [0.22946571, 0.9652117, 1.0], + [0.05355996, 0.9938835, 1.0], + [0.0004149077, 0.9998538, 1.0], + [8.350738e-06, 0.9999956, 1.0], + [0.0, 1.0, 1.0], + [-0.037329286, 0.9731236, 1.0], + [0.006478223, 0.9956182, 1.0], + [0.00065915973, 0.9999197, 1.0], + [2.1461079e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [0.038507767, 0.98602444, 1.0], + [0.009278379, 0.9980656, 1.0], + [0.00043359466, 0.9999601, 1.0], + [1.2074154e-05, 0.99999934, 1.0], + [0.0, 1.0, 1.0], + [0.024321932, 0.99058217, 1.0], + [0.0005276345, 0.99881136, 1.0], + [-0.000108261214, 0.99998933, 1.0], + [-2.0593393e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.08471028, 0.98001426, 1.0], + [0.007567339, 0.9977277, 1.0], + [-0.000107173975, 0.9999823, 1.0], + [-1.4792153e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.21450363, 0.97752666, 1.0], + [0.027476575, 0.9972575, 1.0], + [1.0573019e-05, 0.99997705, 1.0], + [-2.127626e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.13710256, 0.98749036, 1.0], + [0.021214448, 0.9985921, 1.0], + [0.00022974156, 0.99998766, 1.0], + [6.995456e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.034353305, 0.9869362, 1.0], + [-0.0033373374, 0.99891263, 1.0], + [-0.0008769339, 0.99998987, 1.0], + [-6.4116425e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.029380038, 0.9895625, 1.0], + [-0.010520089, 0.99891555, 1.0], + [-0.0019082854, 0.9999861, 1.0], + [-1.3866463e-05, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.085345775, 0.9883013, 1.0], + [-0.0024072328, 0.99874765, 1.0], + [-0.001102076, 0.99999017, 1.0], + [-6.650834e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.09333435, 0.9841484, 1.0], + [0.007872991, 0.99843925, 1.0], + [-0.00031172566, 0.9999886, 1.0], + [-2.2500953e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.1495411, 0.9844692, 1.0], + [-0.009347158, 0.9987978, 1.0], + [-4.104763e-05, 0.9999937, 1.0], + [-4.1019636e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.13588922, 0.99047935, 1.0], + [-0.011877171, 0.9992225, 1.0], + [-0.00016244329, 0.99999523, 1.0], + [-4.7618443e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.18492526, 0.98800945, 1.0], + [-0.01919961, 0.9989189, 1.0], + [-0.00018170738, 0.9999931, 1.0], + [-2.8776438e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.5042778, 0.9758248, 1.0], + [-0.05973959, 0.99744993, 1.0], + [-0.00048369938, 0.99998105, 1.0], + [-3.4633175e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-1.1252605, 0.9543784, 1.0], + [-0.18540663, 0.99284697, 1.0], + [-0.0028871074, 0.99987376, 1.0], + [-9.758851e-06, 0.99999946, 1.0], + [0.0, 1.0, 1.0], + [-1.6847491, 0.9297688, 1.0], + [-0.30172828, 0.9871863, 1.0], + [-0.006863096, 0.9996188, 1.0], + [-3.727347e-05, 0.99999744, 1.0], + [0.0, 1.0, 1.0], + [-2.370997, 0.89369446, 1.0], + [-1.2190995, 0.9472215, 1.0], + [-0.12854093, 0.99495983, 1.0], + [-0.0013108054, 0.999956, 1.0], + [0.0, 1.0, 1.0], + [-4.9047966, 0.7847389, 1.0], + [-5.3030334, 0.8162339, 1.0], + [-1.4172684, 0.9577376, 1.0], + [-0.026107062, 0.999312, 1.0], + [0.0, 1.0, 1.0], + [-5.9655056, 0.73402, 1.0], + [-5.2766113, 0.82234603, 1.0], + [-1.8166275, 0.95730686, 1.0], + [-0.073980905, 0.99863815, 1.0], + [0.0, 1.0, 1.0], + [-7.649344, 0.66660523, 1.0], + [-6.673369, 0.77574635, 1.0], + [-3.8847363, 0.9271979, 1.0], + [-0.35958254, 0.9939365, 1.0], + [0.0, 1.0, 1.0], + [-8.770011, 0.6152831, 1.0], + [-8.663781, 0.6730373, 1.0], + [-4.5796795, 0.8964258, 1.0], + [-0.38371933, 0.9933861, 1.0], + [0.0, 1.0, 1.0], + [-9.064724, 0.563459, 1.0], + [-8.883273, 0.5998541, 1.0], + [-3.3382602, 0.8610413, 1.0], + [-0.06820223, 0.9978487, 1.0], + [0.0, 1.0, 1.0], + [-8.522412, 0.5443333, 1.0], + [-10.088787, 0.5584405, 1.0], + [-8.74115, 0.69652474, 1.0], + [-0.46212718, 0.9844496, 1.0], + [0.0, 1.0, 1.0], + [-5.3416877, 0.5573365, 1.0], + [-10.180397, 0.54992926, 1.0], + [-10.34654, 0.65771925, 1.0], + [-2.239636, 0.95445883, 1.0], + [0.0, 1.0, 1.0], + [-0.17010239, 0.6200763, 1.0], + [-3.7829561, 0.64616305, 1.0], + [-7.5884185, 0.8539007, 1.0], + [-7.036888, 0.9037384, 1.0], + [0.0, 1.0, 1.0], + [2.296787, 0.8043461, 1.0], + [1.2797425, 0.88472384, 1.0], + [-2.9147022, 0.94889057, 1.0], + [-2.639398, 0.96286654, 1.0], + [0.0, 1.0, 1.0], + [1.1746049, 0.87596625, 1.0], + [2.4819775, 0.8980377, 1.0], + [0.43790334, 0.9830677, 1.0], + [-0.006361744, 0.9996459, 1.0], + [0.0, 1.0, 1.0], + [1.4755538, 0.92148954, 1.0], + [3.057467, 0.9028049, 1.0], + [0.5373922, 0.9829736, 1.0], + [0.005417032, 0.99982744, 1.0], + [0.0, 1.0, 1.0], + [0.19876552, 0.97760195, 1.0], + [0.26070496, 0.99011695, 1.0], + [0.033617545, 0.9989797, 1.0], + [0.0002978694, 0.99999154, 1.0], + [0.0, 1.0, 1.0], + [0.07401737, 0.9809595, 1.0], + [0.0669684, 0.9947014, 1.0], + [0.0036516946, 0.99973106, 1.0], + [1.5824207e-05, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [-0.24908549, 0.9819961, 1.0], + [-0.027645566, 0.9976866, 1.0], + [-0.00042068172, 0.9999474, 1.0], + [1.7829321e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.45939854, 0.97829735, 1.0], + [-0.06557831, 0.99736834, 1.0], + [-0.0014225548, 0.99993753, 1.0], + [7.041205e-07, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-0.97001255, 0.94978815, 1.0], + [-0.06016365, 0.9956392, 1.0], + [0.0005834204, 0.9998938, 1.0], + [1.0331767e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [0.8007346, 0.9450692, 1.0], + [0.061064422, 0.9958661, 1.0], + [0.0004179476, 0.99996316, 1.0], + [-5.38278e-07, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [0.7554829, 0.97007614, 1.0], + [0.038412966, 0.99764246, 1.0], + [-1.2250363e-05, 0.99998575, 1.0], + [-4.3817803e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.4214841, 0.98421025, 1.0], + [0.018357918, 0.9986999, 1.0], + [-0.0001539443, 0.99998885, 1.0], + [-2.1144313e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.26302785, 0.9877291, 1.0], + [0.016418263, 0.99897736, 1.0], + [0.00017517614, 0.999994, 1.0], + [3.7461887e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.2549705, 0.9871261, 1.0], + [0.019887371, 0.99840975, 1.0], + [-3.7766345e-05, 0.99995524, 1.0], + [-7.270728e-06, 0.9999991, 1.0], + [0.0, 1.0, 1.0], + [0.57600856, 0.9830809, 1.0], + [0.028567612, 0.99657667, 1.0], + [-0.00476558, 0.99963075, 1.0], + [-0.00016184518, 0.9999903, 1.0], + [0.0, 1.0, 1.0], + [0.023581166, 0.9658987, 1.0], + [-0.12978074, 0.9894591, 1.0], + [-0.010897471, 0.999496, 1.0], + [-0.0003179809, 0.9999912, 1.0], + [0.0, 1.0, 1.0], + [-0.48003206, 0.947442, 1.0], + [-0.28566536, 0.98114216, 1.0], + [-0.02372709, 0.99919075, 1.0], + [-0.0012802386, 0.9999792, 1.0], + [0.0, 1.0, 1.0], + [-0.30012533, 0.9344908, 1.0], + [-0.415363, 0.9781973, 1.0], + [-0.04023375, 0.99892974, 1.0], + [-0.002275092, 0.9999678, 1.0], + [0.0, 1.0, 1.0], + [1.2335646, 0.91651773, 1.0], + [-0.35089287, 0.9740984, 1.0], + [-0.03576802, 0.9988748, 1.0], + [-0.0019647575, 0.99997276, 1.0], + [0.0, 1.0, 1.0], + [4.4362745, 0.8536556, 1.0], + [-0.5057072, 0.96044815, 1.0], + [-0.05471109, 0.99809283, 1.0], + [-0.00035249075, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [12.081045, 0.7132523, 1.0], + [0.5422661, 0.9341002, 1.0], + [-0.06538023, 0.99676913, 1.0], + [-0.00047848828, 0.9999808, 1.0], + [0.0, 1.0, 1.0], + [17.529392, 0.60886335, 1.0], + [1.5537803, 0.8626087, 1.0], + [-0.3528088, 0.98406494, 1.0], + [-0.0052189324, 0.99973387, 1.0], + [0.0, 1.0, 1.0], + [18.210768, 0.5879146, 1.0], + [1.4481329, 0.77672154, 1.0], + [-0.9064065, 0.9183754, 1.0], + [-0.024934258, 0.99743956, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-profiles/NIKON_D5300.json b/assets/camera-profiles/NIKON_D5300.json new file mode 100644 index 0000000000..5ab1f062e4 --- /dev/null +++ b/assets/camera-profiles/NIKON_D5300.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON D5300", + "files": 264, + "samples": 964687, + "matrix": [ + [1.1916187747366802, -0.24231291824981582, 0.06983947565237258], + [-0.07690142035770187, 1.1730235959883188, -0.10538788116290476], + [0.03036949208278017, -0.9047958593485248, 1.8955506437912675] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [-0.27515984, 1.1516873, 1.0], + [1.6639282, 1.1351492, 1.0], + [3.788241, 1.0706222, 1.0], + [2.7755148, 1.0334021, 1.0], + [0.0, 1.0, 1.0], + [0.14424162, 1.1334472, 1.0], + [1.3097183, 1.1570547, 1.0], + [2.2948222, 1.0653765, 1.0], + [2.48696, 1.0126696, 1.0], + [0.0, 1.0, 1.0], + [0.39059362, 1.108461, 1.0], + [0.91269153, 1.1615747, 1.0], + [1.2193394, 1.1302495, 1.0], + [1.564095, 1.0433257, 1.0], + [0.0, 1.0, 1.0], + [0.37582052, 1.0874242, 1.0], + [0.37180975, 1.12975, 1.0], + [0.08854759, 1.119602, 1.0], + [0.12284424, 1.0544983, 1.0], + [0.0, 1.0, 1.0], + [0.41708267, 1.0888891, 1.0], + [0.06426376, 1.1006688, 1.0], + [-0.8100427, 1.0653179, 1.0], + [-1.3206435, 1.0191233, 1.0], + [0.0, 1.0, 1.0], + [0.13243157, 1.080272, 1.0], + [-0.96863246, 1.0961237, 1.0], + [-2.4892373, 1.0579139, 1.0], + [-3.6368277, 1.0045699, 1.0], + [0.0, 1.0, 1.0], + [-0.5086707, 1.0768719, 1.0], + [-2.4455884, 1.090032, 1.0], + [-4.392341, 1.0430846, 1.0], + [-5.70884, 0.984795, 1.0], + [0.0, 1.0, 1.0], + [-0.9826923, 1.0976027, 1.0], + [-3.1522026, 1.0984454, 1.0], + [-5.0928793, 1.0361301, 1.0], + [-6.190662, 0.9671035, 1.0], + [0.0, 1.0, 1.0], + [-0.83517295, 1.1328652, 1.0], + [-2.3040426, 1.1309464, 1.0], + [-3.782865, 1.0633757, 1.0], + [-5.345632, 0.9701844, 1.0], + [0.0, 1.0, 1.0], + [0.5699928, 1.1778497, 1.0], + [-0.5092877, 1.1737088, 1.0], + [-1.6260748, 1.0975113, 1.0], + [-3.6590147, 0.97431755, 1.0], + [0.0, 1.0, 1.0], + [1.965812, 1.2052466, 1.0], + [1.2205318, 1.1937522, 1.0], + [0.32865468, 1.1070911, 1.0], + [-0.5527478, 0.9777932, 1.0], + [0.0, 1.0, 1.0], + [2.1100607, 1.198459, 1.0], + [2.1056213, 1.1429148, 1.0], + [1.3356481, 1.0316085, 1.0], + [0.5096412, 0.93477476, 1.0], + [0.0, 1.0, 1.0], + [1.4636977, 1.2118869, 1.0], + [1.7222096, 1.1241792, 1.0], + [0.98221314, 1.0024475, 1.0], + [0.21372011, 0.9131384, 1.0], + [0.0, 1.0, 1.0], + [0.5342778, 1.1797847, 1.0], + [0.957812, 1.0956764, 1.0], + [0.90817916, 1.0003859, 1.0], + [0.27506226, 0.91949815, 1.0], + [0.0, 1.0, 1.0], + [0.18069139, 1.1459695, 1.0], + [1.0440427, 1.0779425, 1.0], + [1.4630631, 0.9941303, 1.0], + [1.2396606, 0.87395155, 1.0], + [0.0, 1.0, 1.0], + [0.18410955, 1.0979248, 1.0], + [1.3498847, 1.0526143, 1.0], + [2.2518015, 0.97271824, 1.0], + [2.5790663, 0.85005623, 1.0], + [0.0, 1.0, 1.0], + [0.86806947, 1.0538092, 1.0], + [2.255343, 1.0105029, 1.0], + [3.711173, 0.92738235, 1.0], + [4.5281615, 0.85241896, 1.0], + [0.0, 1.0, 1.0], + [1.1754395, 1.0296917, 1.0], + [2.5134487, 0.98583174, 1.0], + [3.7772336, 0.90998256, 1.0], + [4.45152, 0.85479385, 1.0], + [0.0, 1.0, 1.0], + [1.1445321, 1.0435532, 1.0], + [2.0396032, 0.9995336, 1.0], + [3.0725362, 0.9361685, 1.0], + [3.631579, 0.8885794, 1.0], + [0.0, 1.0, 1.0], + [0.7827189, 1.0589092, 1.0], + [1.0505862, 1.0227683, 1.0], + [1.7957299, 0.98822904, 1.0], + [1.967721, 0.9724715, 1.0], + [0.0, 1.0, 1.0], + [-0.29322907, 1.06687, 1.0], + [-0.08487194, 1.0363688, 1.0], + [0.55795294, 1.0033514, 1.0], + [0.38443246, 0.9958227, 1.0], + [0.0, 1.0, 1.0], + [-0.20798732, 1.0558138, 1.0], + [-0.22798188, 1.0087736, 1.0], + [-0.27102852, 0.97902, 1.0], + [-0.030924844, 0.9963477, 1.0], + [0.0, 1.0, 1.0], + [-0.6630197, 1.0661411, 1.0], + [-1.1126446, 1.0020812, 1.0], + [-0.8216712, 0.96949637, 1.0], + [-0.05875481, 0.99697804, 1.0], + [0.0, 1.0, 1.0], + [-1.306994, 1.0804613, 1.0], + [-1.9421868, 1.0075339, 1.0], + [-0.4435876, 0.9711658, 1.0], + [-0.009687971, 0.9980309, 1.0], + [0.0, 1.0, 1.0], + [-1.1269542, 1.0775944, 1.0], + [-1.3976283, 1.0217266, 1.0], + [-0.24913609, 0.98865783, 1.0], + [-0.0031247279, 0.9994072, 1.0], + [0.0, 1.0, 1.0], + [-1.375694, 1.0512048, 1.0], + [-0.75242716, 1.0082058, 1.0], + [-0.06595289, 0.99760807, 1.0], + [-0.0031345324, 0.99984926, 1.0], + [0.0, 1.0, 1.0], + [-1.7031127, 1.0458598, 1.0], + [-1.1684496, 1.0118368, 1.0], + [-0.1479137, 0.9961412, 1.0], + [-0.0055244397, 0.9997797, 1.0], + [0.0, 1.0, 1.0], + [-1.7284406, 1.0431077, 1.0], + [-0.73593587, 1.002617, 1.0], + [0.02080173, 0.9911072, 1.0], + [0.00041606076, 0.9996808, 1.0], + [0.0, 1.0, 1.0], + [-1.2268276, 1.0388371, 1.0], + [-1.0188804, 1.0028651, 1.0], + [-0.10369495, 0.9951023, 1.0], + [-0.00074021873, 0.99985033, 1.0], + [0.0, 1.0, 1.0], + [-0.4187577, 1.0261325, 1.0], + [-1.5957482, 0.9980721, 1.0], + [-0.1547406, 0.99747145, 1.0], + [0.006172743, 0.99982816, 1.0], + [0.0, 1.0, 1.0], + [0.17795917, 1.0130396, 1.0], + [-0.6837107, 0.98732287, 1.0], + [-0.06094904, 0.9973522, 1.0], + [0.004337359, 0.99988484, 1.0], + [0.0, 1.0, 1.0], + [2.0028615, 1.0350925, 1.0], + [0.68680644, 1.000726, 1.0], + [0.019622738, 0.99780047, 1.0], + [0.0016065001, 0.999882, 1.0], + [0.0, 1.0, 1.0], + [2.0603294, 1.019478, 1.0], + [0.38180757, 0.983154, 1.0], + [-0.23340763, 0.98130405, 1.0], + [-0.0110910535, 0.9989576, 1.0], + [0.0, 1.0, 1.0], + [2.5435147, 1.0271243, 1.0], + [0.99150276, 0.989704, 1.0], + [-0.28383088, 0.98094606, 1.0], + [-0.020072438, 0.9989133, 1.0], + [0.0, 1.0, 1.0], + [3.08294, 1.0426484, 1.0], + [1.818317, 1.0104465, 1.0], + [0.061887734, 0.99672973, 1.0], + [-0.003478195, 0.99980235, 1.0], + [0.0, 1.0, 1.0], + [3.420556, 1.0468477, 1.0], + [1.3910332, 0.9856526, 1.0], + [0.014045477, 0.992967, 1.0], + [-0.0012650526, 0.99988115, 1.0], + [0.0, 1.0, 1.0], + [1.0608708, 1.0270871, 1.0], + [-0.91338277, 0.92517686, 1.0], + [-0.4462344, 0.9653749, 1.0], + [-0.00868847, 0.99921674, 1.0], + [0.0, 1.0, 1.0], + [-0.41799447, 1.0205152, 1.0], + [-1.6170602, 0.9293957, 1.0], + [-0.6444141, 0.96566117, 1.0], + [-0.013291724, 0.99908036, 1.0], + [0.0, 1.0, 1.0], + [0.30684704, 1.0309951, 1.0], + [-0.32407844, 0.9751206, 1.0], + [-0.2714902, 0.9828883, 1.0], + [-0.00732241, 0.9995556, 1.0], + [0.0, 1.0, 1.0], + [0.23834689, 1.0267385, 1.0], + [0.17927068, 0.98710644, 1.0], + [0.18505995, 0.988422, 1.0], + [0.007564359, 0.99952435, 1.0], + [0.0, 1.0, 1.0], + [0.9417192, 1.0186211, 1.0], + [0.4331768, 0.98655975, 1.0], + [0.5120104, 0.973346, 1.0], + [0.046577834, 0.9975678, 1.0], + [0.0, 1.0, 1.0], + [0.37069434, 1.015258, 1.0], + [-0.33970982, 1.0002911, 1.0], + [0.21809609, 0.9738671, 1.0], + [0.05885403, 0.996412, 1.0], + [0.0, 1.0, 1.0], + [0.3410674, 1.0226996, 1.0], + [-0.46905938, 1.0094384, 1.0], + [1.1913799, 0.9839222, 1.0], + [0.27729467, 0.9954679, 1.0], + [0.0, 1.0, 1.0], + [1.8399196, 1.0310917, 1.0], + [1.3589114, 1.0043445, 1.0], + [3.2229822, 0.9866625, 1.0], + [0.79395044, 0.9953453, 1.0], + [0.0, 1.0, 1.0], + [3.6512873, 1.0852343, 1.0], + [3.6366863, 1.0575762, 1.0], + [4.409545, 1.0124124, 1.0], + [1.2255954, 0.9980612, 1.0], + [0.0, 1.0, 1.0], + [3.5640118, 1.103434, 1.0], + [3.857803, 1.0989252, 1.0], + [5.013495, 1.0628691, 1.0], + [2.1981053, 1.0128543, 1.0], + [0.0, 1.0, 1.0], + [3.750485, 1.1216393, 1.0], + [4.4771504, 1.1242076, 1.0], + [5.554026, 1.098979, 1.0], + [4.0766697, 1.0487705, 1.0], + [0.0, 1.0, 1.0], + [3.716854, 1.1420878, 1.0], + [3.9434807, 1.149656, 1.0], + [4.698269, 1.1275396, 1.0], + [3.355159, 1.0637993, 1.0], + [0.0, 1.0, 1.0], + [3.1019642, 1.152162, 1.0], + [3.018406, 1.1701936, 1.0], + [3.5190942, 1.1440179, 1.0], + [1.6282337, 1.0440116, 1.0], + [0.0, 1.0, 1.0], + [3.0533535, 1.1430386, 1.0], + [2.6682806, 1.1609282, 1.0], + [1.6297065, 1.1209588, 1.0], + [0.3274113, 1.0186641, 1.0], + [0.0, 1.0, 1.0], + [2.7143104, 1.1457591, 1.0], + [2.010889, 1.1753439, 1.0], + [1.1698594, 1.1809243, 1.0], + [0.26954132, 1.0447038, 1.0], + [0.0, 1.0, 1.0], + [1.9450912, 1.1625692, 1.0], + [1.4925617, 1.1576607, 1.0], + [0.73646855, 1.1099164, 1.0], + [0.041240156, 1.0114015, 1.0], + [0.0, 1.0, 1.0], + [1.4923553, 1.1628035, 1.0], + [0.9318059, 1.1155237, 1.0], + [-0.08695841, 1.0332942, 1.0], + [-0.048176043, 1.0041637, 1.0], + [0.0, 1.0, 1.0], + [1.084317, 1.1418072, 1.0], + [0.077919334, 1.1205045, 1.0], + [-0.37862262, 1.0519394, 1.0], + [-0.020748373, 1.0024971, 1.0], + [0.0, 1.0, 1.0], + [0.70962113, 1.1346613, 1.0], + [0.040982615, 1.1304044, 1.0], + [-0.59360516, 1.0895824, 1.0], + [-0.044442892, 1.0063348, 1.0], + [0.0, 1.0, 1.0], + [0.044502545, 1.1331831, 1.0], + [-0.31845695, 1.1328877, 1.0], + [-0.70814407, 1.0888221, 1.0], + [-0.053025763, 1.0063571, 1.0], + [0.0, 1.0, 1.0], + [0.102819525, 1.0983768, 1.0], + [-0.41320392, 1.0724556, 1.0], + [-0.34162834, 1.0306761, 1.0], + [-0.015480887, 1.0013198, 1.0], + [0.0, 1.0, 1.0], + [-0.45333293, 1.1152055, 1.0], + [-0.36607388, 1.0825105, 1.0], + [-0.061023094, 1.0260044, 1.0], + [-0.00076291023, 1.0008935, 1.0], + [0.0, 1.0, 1.0], + [-1.5181267, 1.1252426, 1.0], + [-0.7772274, 1.0954831, 1.0], + [0.07648249, 1.0305958, 1.0], + [0.007827844, 1.0011276, 1.0], + [0.0, 1.0, 1.0], + [-2.7173598, 1.1436788, 1.0], + [-1.1496428, 1.0798888, 1.0], + [0.13708575, 1.0121282, 1.0], + [0.0056166523, 1.0002311, 1.0], + [0.0, 1.0, 1.0], + [-2.8031726, 1.1485914, 1.0], + [-1.1996536, 1.065685, 1.0], + [-0.12146866, 1.0105418, 1.0], + [-0.001331464, 1.000203, 1.0], + [0.0, 1.0, 1.0], + [-3.7381613, 1.1670295, 1.0], + [-2.4761944, 1.0762824, 1.0], + [-0.30495355, 1.010074, 1.0], + [-0.003095148, 1.0001698, 1.0], + [0.0, 1.0, 1.0], + [-2.7574484, 1.1367188, 1.0], + [-1.0683595, 1.0591327, 1.0], + [0.3032825, 1.0109986, 1.0], + [0.0240107, 1.0003924, 1.0], + [0.0, 1.0, 1.0], + [-4.4444375, 1.1478848, 1.0], + [-2.10396, 1.077343, 1.0], + [0.39878115, 1.0108056, 1.0], + [0.04858238, 1.0006601, 1.0], + [0.0, 1.0, 1.0], + [-3.6460211, 1.1262141, 1.0], + [-0.94227576, 1.0764561, 1.0], + [0.46847248, 1.0121009, 1.0], + [0.056671463, 1.0005428, 1.0], + [0.0, 1.0, 1.0], + [-3.941849, 1.1401461, 1.0], + [-1.4121957, 1.0967879, 1.0], + [0.3250404, 1.0172433, 1.0], + [0.05624255, 1.0007424, 1.0], + [0.0, 1.0, 1.0], + [-3.3003352, 1.1373972, 1.0], + [-1.2239021, 1.0964172, 1.0], + [0.4203376, 1.0171689, 1.0], + [0.097768836, 1.0010349, 1.0], + [0.0, 1.0, 1.0], + [-1.3678734, 1.1021602, 1.0], + [0.18997674, 1.0791068, 1.0], + [1.9053582, 1.0272077, 1.0], + [0.36325264, 1.0027457, 1.0], + [0.0, 1.0, 1.0], + [-1.888127, 1.1030134, 1.0], + [0.9222494, 1.0793839, 1.0], + [4.407062, 1.0317812, 1.0], + [1.2517463, 1.0053726, 1.0], + [0.0, 1.0, 1.0], + [-0.87627596, 1.1100941, 1.0], + [2.1658633, 1.0810852, 1.0], + [6.2091784, 1.0344337, 1.0], + [2.7591643, 1.0083429, 1.0], + [0.0, 1.0, 1.0], + [0.58831537, 1.1441597, 1.0], + [3.26025, 1.1017427, 1.0], + [5.9591794, 1.0396717, 1.0], + [2.5558283, 1.009373, 1.0], + [0.0, 1.0, 1.0], + [0.6157835, 1.1522069, 1.0], + [2.7376304, 1.1033771, 1.0], + [5.0446105, 1.0609956, 1.0], + [2.545954, 1.0267637, 1.0], + [0.0, 1.0, 1.0], + [2.567323, 1.050905, 1.0], + [3.219447, 1.086401, 1.0], + [5.309476, 1.0623287, 1.0], + [5.3261275, 1.0507418, 1.0], + [0.0, 1.0, 1.0], + [2.4884005, 1.039593, 1.0], + [2.2780461, 1.0956311, 1.0], + [3.2725804, 1.0421693, 1.0], + [3.57421, 1.0101761, 1.0], + [0.0, 1.0, 1.0], + [2.709007, 1.016824, 1.0], + [1.9036032, 1.1006229, 1.0], + [1.8250041, 1.09863, 1.0], + [2.2753541, 1.0252498, 1.0], + [0.0, 1.0, 1.0], + [2.8476596, 1.0087867, 1.0], + [1.692206, 1.0733916, 1.0], + [0.98378277, 1.0820075, 1.0], + [0.71567744, 1.0277224, 1.0], + [0.0, 1.0, 1.0], + [2.7584476, 1.0095731, 1.0], + [1.4141955, 1.0379248, 1.0], + [0.30707088, 1.0278721, 1.0], + [-0.48991957, 0.99570155, 1.0], + [0.0, 1.0, 1.0], + [2.3251288, 1.0099978, 1.0], + [0.40751106, 1.034089, 1.0], + [-1.266682, 1.0152951, 1.0], + [-2.8792267, 0.97016555, 1.0], + [0.0, 1.0, 1.0], + [1.676751, 1.0196159, 1.0], + [-1.0043784, 1.0388186, 1.0], + [-3.3858006, 1.0105125, 1.0], + [-5.2836957, 0.95619494, 1.0], + [0.0, 1.0, 1.0], + [1.4230912, 1.0371325, 1.0], + [-1.6468483, 1.0507761, 1.0], + [-4.205406, 1.0050639, 1.0], + [-5.696899, 0.9382988, 1.0], + [0.0, 1.0, 1.0], + [2.215277, 1.0757174, 1.0], + [-0.13039589, 1.0800881, 1.0], + [-2.5378978, 1.0185709, 1.0], + [-4.5910883, 0.941271, 1.0], + [0.0, 1.0, 1.0], + [3.7552316, 1.1195593, 1.0], + [1.9935799, 1.1178633, 1.0], + [-0.1950342, 1.0336235, 1.0], + [-2.9394403, 0.9406914, 1.0], + [0.0, 1.0, 1.0], + [4.6019835, 1.1451467, 1.0], + [3.4129214, 1.1365347, 1.0], + [2.2300456, 1.0304257, 1.0], + [0.6699211, 0.8971019, 1.0], + [0.0, 1.0, 1.0], + [4.556115, 1.1341692, 1.0], + [3.8200648, 1.0857233, 1.0], + [3.1856856, 0.96298456, 1.0], + [2.2841954, 0.8578174, 1.0], + [0.0, 1.0, 1.0], + [3.4975002, 1.1417533, 1.0], + [2.8865607, 1.0605808, 1.0], + [2.4713562, 0.93972397, 1.0], + [1.982328, 0.83509517, 1.0], + [0.0, 1.0, 1.0], + [2.0893435, 1.1080577, 1.0], + [1.7293227, 1.0274619, 1.0], + [1.6507962, 0.9354451, 1.0], + [1.3905607, 0.8366557, 1.0], + [0.0, 1.0, 1.0], + [1.6415445, 1.0806029, 1.0], + [1.506959, 1.0187534, 1.0], + [1.6388204, 0.9282385, 1.0], + [1.8973097, 0.82975906, 1.0], + [0.0, 1.0, 1.0], + [1.7991877, 1.0542749, 1.0], + [2.0936208, 1.0012176, 1.0], + [2.6603212, 0.9100169, 1.0], + [3.5249443, 0.80724543, 1.0], + [0.0, 1.0, 1.0], + [2.1576753, 1.0242636, 1.0], + [2.7907417, 0.9638929, 1.0], + [3.652071, 0.87338793, 1.0], + [4.5204115, 0.78994405, 1.0], + [0.0, 1.0, 1.0], + [2.1243021, 1.008548, 1.0], + [2.7552125, 0.9425203, 1.0], + [3.522211, 0.8644462, 1.0], + [4.1264706, 0.8124369, 1.0], + [0.0, 1.0, 1.0], + [1.8834304, 1.0225105, 1.0], + [2.3353438, 0.9708129, 1.0], + [3.0901859, 0.9099647, 1.0], + [3.7211735, 0.86348975, 1.0], + [0.0, 1.0, 1.0], + [1.4581822, 1.0330464, 1.0], + [1.5594033, 0.99513304, 1.0], + [2.0912547, 0.96644175, 1.0], + [2.5819116, 0.94535977, 1.0], + [0.0, 1.0, 1.0], + [0.60412645, 1.0405735, 1.0], + [0.5539686, 1.0038283, 1.0], + [0.8588483, 0.9796771, 1.0], + [0.7651864, 0.98082924, 1.0], + [0.0, 1.0, 1.0], + [0.19108707, 1.0352545, 1.0], + [-0.16049986, 0.99251735, 1.0], + [-0.5080386, 0.96194357, 1.0], + [-0.33787724, 0.98708415, 1.0], + [0.0, 1.0, 1.0], + [-0.24877733, 1.0316597, 1.0], + [-0.70576507, 0.97597045, 1.0], + [-0.8016234, 0.9442628, 1.0], + [-0.11087948, 0.9908073, 1.0], + [0.0, 1.0, 1.0], + [-0.8790754, 1.0514538, 1.0], + [-1.4108989, 0.9916971, 1.0], + [-1.0544907, 0.9481936, 1.0], + [-0.10507189, 0.99393654, 1.0], + [0.0, 1.0, 1.0], + [-1.3867714, 1.0623717, 1.0], + [-2.1656637, 1.0158321, 1.0], + [-1.1665387, 0.9772894, 1.0], + [-0.049957793, 0.99827534, 1.0], + [0.0, 1.0, 1.0], + [-2.4962778, 1.047387, 1.0], + [-3.1633992, 1.0073104, 1.0], + [-1.4553053, 0.95946413, 1.0], + [-0.13852488, 0.99207044, 1.0], + [0.0, 1.0, 1.0], + [-3.1582537, 1.039661, 1.0], + [-3.3594494, 0.99911463, 1.0], + [-1.4694877, 0.9412946, 1.0], + [-0.15061137, 0.9906891, 1.0], + [0.0, 1.0, 1.0], + [-3.7993233, 1.040663, 1.0], + [-3.003692, 0.9947964, 1.0], + [-0.8132878, 0.94909513, 1.0], + [-0.09164102, 0.99424165, 1.0], + [0.0, 1.0, 1.0], + [-3.1300888, 1.0289713, 1.0], + [-2.916435, 0.98529565, 1.0], + [-0.3949024, 0.9686243, 1.0], + [0.057513624, 0.99605733, 1.0], + [0.0, 1.0, 1.0], + [-2.870443, 1.0128809, 1.0], + [-3.22342, 0.97333175, 1.0], + [1.0390259, 0.9529574, 1.0], + [0.3107253, 0.99131966, 1.0], + [0.0, 1.0, 1.0], + [-1.9573697, 1.0006245, 1.0], + [-1.8365934, 0.9674953, 1.0], + [0.7572013, 0.9682651, 1.0], + [0.17691553, 0.9942757, 1.0], + [0.0, 1.0, 1.0], + [-0.28490174, 1.0081447, 1.0], + [-0.29309836, 0.97951233, 1.0], + [0.18393767, 0.98060596, 1.0], + [0.007778787, 0.997274, 1.0], + [0.0, 1.0, 1.0], + [0.2726132, 0.99976957, 1.0], + [-0.4088234, 0.95960116, 1.0], + [0.0077163163, 0.9512176, 1.0], + [0.019507347, 0.9959901, 1.0], + [0.0, 1.0, 1.0], + [0.7798813, 0.9985067, 1.0], + [0.76253676, 0.9651048, 1.0], + [-0.1965283, 0.95622796, 1.0], + [-0.024494128, 0.99734217, 1.0], + [0.0, 1.0, 1.0], + [1.2395806, 1.0156081, 1.0], + [1.9043974, 0.98719865, 1.0], + [0.2547382, 0.9865172, 1.0], + [-0.0017040195, 0.99939626, 1.0], + [0.0, 1.0, 1.0], + [2.3556528, 1.0269231, 1.0], + [1.6541926, 0.94798446, 1.0], + [0.19142385, 0.96310043, 1.0], + [0.00077345694, 0.99907255, 1.0], + [0.0, 1.0, 1.0], + [0.8628551, 1.0025055, 1.0], + [-0.8925193, 0.86057365, 1.0], + [-0.89507776, 0.8785938, 1.0], + [-0.02676659, 0.9942501, 1.0], + [0.0, 1.0, 1.0], + [-0.69604295, 0.9841022, 1.0], + [-1.5614027, 0.86573595, 1.0], + [-1.0682615, 0.8759204, 1.0], + [-0.053552028, 0.992165, 1.0], + [0.0, 1.0, 1.0], + [-1.3843973, 0.9732926, 1.0], + [-1.6747307, 0.9024153, 1.0], + [-1.1190381, 0.90966296, 1.0], + [-0.0430858, 0.995535, 1.0], + [0.0, 1.0, 1.0], + [-1.4726397, 0.9728779, 1.0], + [-0.48264512, 0.9410372, 1.0], + [0.31483912, 0.93758917, 1.0], + [0.029700607, 0.99565494, 1.0], + [0.0, 1.0, 1.0], + [-1.5699832, 0.9680764, 1.0], + [-0.5216335, 0.9524717, 1.0], + [1.1201954, 0.9122498, 1.0], + [0.2576845, 0.9783837, 1.0], + [0.0, 1.0, 1.0], + [-1.9922228, 0.9569479, 1.0], + [-1.1839337, 0.9620515, 1.0], + [0.49700478, 0.90824383, 1.0], + [0.2757639, 0.9656649, 1.0], + [0.0, 1.0, 1.0], + [-1.8267179, 0.97236925, 1.0], + [-1.3503194, 0.98332787, 1.0], + [-0.21534178, 0.9398797, 1.0], + [0.3592183, 0.9531863, 1.0], + [0.0, 1.0, 1.0], + [-1.3845943, 0.9576693, 1.0], + [-0.88324434, 0.95311546, 1.0], + [1.0021961, 0.92629516, 1.0], + [1.4048795, 0.9467104, 1.0], + [0.0, 1.0, 1.0], + [0.21868584, 0.9868099, 1.0], + [1.2409965, 0.98529285, 1.0], + [3.478748, 0.96618646, 1.0], + [3.547536, 0.9636579, 1.0], + [0.0, 1.0, 1.0], + [0.72944003, 1.011865, 1.0], + [2.2030432, 1.0459692, 1.0], + [4.3416624, 1.0307989, 1.0], + [4.5565796, 1.0013582, 1.0], + [0.0, 1.0, 1.0], + [0.7054098, 1.0304452, 1.0], + [2.7193584, 1.0797284, 1.0], + [4.6252937, 1.0782566, 1.0], + [5.346714, 1.0554028, 1.0], + [0.0, 1.0, 1.0], + [0.8206882, 1.0563322, 1.0], + [2.66612, 1.105401, 1.0], + [4.257176, 1.1014127, 1.0], + [5.333192, 1.0896508, 1.0], + [0.0, 1.0, 1.0], + [0.6858709, 1.0573044, 1.0], + [2.1161485, 1.1302112, 1.0], + [3.9404676, 1.1377732, 1.0], + [4.928983, 1.1188648, 1.0], + [0.0, 1.0, 1.0], + [0.4658943, 1.0244572, 1.0], + [1.4814435, 1.1276039, 1.0], + [2.0604527, 1.1611818, 1.0], + [1.7115017, 1.0927407, 1.0], + [0.0, 1.0, 1.0], + [0.66739404, 1.0136315, 1.0], + [1.41378, 1.1620725, 1.0], + [1.2858282, 1.2084693, 1.0], + [0.8847436, 1.1387153, 1.0], + [0.0, 1.0, 1.0], + [0.22088455, 1.0020697, 1.0], + [0.8503165, 1.1110431, 1.0], + [0.8370134, 1.1636014, 1.0], + [0.16764726, 1.0484313, 1.0], + [0.0, 1.0, 1.0], + [-0.032334913, 0.99697274, 1.0], + [-0.12844399, 1.0419574, 1.0], + [-0.9318103, 1.0669361, 1.0], + [-0.2286045, 1.0165362, 1.0], + [0.0, 1.0, 1.0], + [-0.1669066, 0.99338514, 1.0], + [-0.8307293, 1.0627798, 1.0], + [-1.2913208, 1.0905485, 1.0], + [-0.08585524, 1.0079323, 1.0], + [0.0, 1.0, 1.0], + [0.017506301, 0.9974502, 1.0], + [-0.4714752, 1.0923178, 1.0], + [-1.2507538, 1.1427293, 1.0], + [-0.15650667, 1.0198009, 1.0], + [0.0, 1.0, 1.0], + [0.5337322, 0.98601866, 1.0], + [0.4784788, 1.0887345, 1.0], + [-0.95893174, 1.1386591, 1.0], + [-0.15236506, 1.0194373, 1.0], + [0.0, 1.0, 1.0], + [0.78718185, 0.9737713, 1.0], + [0.8975688, 1.024816, 1.0], + [-0.2945547, 1.0546055, 1.0], + [-0.03156037, 1.0033555, 1.0], + [0.0, 1.0, 1.0], + [0.24685815, 0.98656416, 1.0], + [0.55160105, 1.0425034, 1.0], + [-0.11143197, 1.0422429, 1.0], + [-0.003670774, 1.0018142, 1.0], + [0.0, 1.0, 1.0], + [-0.77873033, 1.0003996, 1.0], + [0.19153872, 1.0427284, 1.0], + [0.37716967, 1.041646, 1.0], + [0.016879126, 1.0023743, 1.0], + [0.0, 1.0, 1.0], + [-1.7304914, 1.0388753, 1.0], + [-0.7251529, 1.0577725, 1.0], + [0.34579277, 1.0164489, 1.0], + [0.009596101, 1.0004158, 1.0], + [0.0, 1.0, 1.0], + [-1.6364738, 1.067764, 1.0], + [-0.9167525, 1.0778942, 1.0], + [0.103218675, 1.0217229, 1.0], + [0.0034776113, 1.0004051, 1.0], + [0.0, 1.0, 1.0], + [-1.7374398, 1.0553571, 1.0], + [-0.8997327, 1.072175, 1.0], + [0.018124623, 1.0228853, 1.0], + [0.0031013442, 1.0004023, 1.0], + [0.0, 1.0, 1.0], + [-1.9461782, 1.0329045, 1.0], + [-0.9284798, 1.0640512, 1.0], + [0.5232761, 1.0302181, 1.0], + [0.0713816, 1.0012505, 1.0], + [0.0, 1.0, 1.0], + [-1.7830942, 1.0167092, 1.0], + [-1.0461223, 1.0536364, 1.0], + [0.71468955, 1.031544, 1.0], + [0.1528099, 1.0023446, 1.0], + [0.0, 1.0, 1.0], + [-0.901455, 1.0192658, 1.0], + [0.3789207, 1.0608308, 1.0], + [1.4712181, 1.0287849, 1.0], + [0.17150511, 1.0013422, 1.0], + [0.0, 1.0, 1.0], + [-1.4228317, 1.0136043, 1.0], + [-0.4306419, 1.0521969, 1.0], + [0.79203665, 1.0235791, 1.0], + [0.17930758, 1.0008605, 1.0], + [0.0, 1.0, 1.0], + [-0.89692557, 1.0078237, 1.0], + [0.22381781, 1.0376579, 1.0], + [1.2225298, 1.0229918, 1.0], + [0.30199468, 1.0024537, 1.0], + [0.0, 1.0, 1.0], + [0.96971756, 0.998565, 1.0], + [2.4854388, 1.0386328, 1.0], + [5.093825, 1.0409368, 1.0], + [1.5859087, 1.0088303, 1.0], + [0.0, 1.0, 1.0], + [2.0574582, 0.99701333, 1.0], + [3.9885697, 1.0335468, 1.0], + [7.3134866, 1.02023, 1.0], + [4.876025, 1.0077791, 1.0], + [0.0, 1.0, 1.0], + [2.9773273, 1.0016919, 1.0], + [4.8199377, 1.0256822, 1.0], + [7.568321, 1.0072774, 1.0], + [6.5177317, 0.99610305, 1.0], + [0.0, 1.0, 1.0], + [3.3437927, 1.0140332, 1.0], + [4.7947435, 1.036412, 1.0], + [7.550965, 1.0280272, 1.0], + [6.326854, 1.0115026, 1.0], + [0.0, 1.0, 1.0], + [2.9055889, 1.0224042, 1.0], + [3.8671165, 1.0454612, 1.0], + [6.3752146, 1.0593462, 1.0], + [5.871097, 1.0545919, 1.0], + [0.0, 1.0, 1.0], + [4.4711437, 0.89240575, 1.0], + [3.8707209, 0.975318, 1.0], + [5.54684, 1.0295455, 1.0], + [5.4792643, 1.0330564, 1.0], + [0.0, 1.0, 1.0], + [4.5822687, 0.86561245, 1.0], + [3.369101, 0.9498307, 1.0], + [4.0309362, 0.99107826, 1.0], + [4.1218996, 0.97818476, 1.0], + [0.0, 1.0, 1.0], + [4.7380633, 0.856774, 1.0], + [3.2887201, 0.9503168, 1.0], + [2.9449494, 1.0068572, 1.0], + [3.3715508, 0.97625136, 1.0], + [0.0, 1.0, 1.0], + [4.9660296, 0.8707871, 1.0], + [3.211986, 0.9548858, 1.0], + [1.9611124, 1.0027668, 1.0], + [1.4650759, 0.9794454, 1.0], + [0.0, 1.0, 1.0], + [4.919991, 0.8776082, 1.0], + [2.96986, 0.92894495, 1.0], + [1.43205, 0.9510039, 1.0], + [0.48539585, 0.9417541, 1.0], + [0.0, 1.0, 1.0], + [4.378387, 0.883634, 1.0], + [2.1083007, 0.919261, 1.0], + [0.5318521, 0.9173686, 1.0], + [-0.7582033, 0.896065, 1.0], + [0.0, 1.0, 1.0], + [3.8246558, 0.8962875, 1.0], + [0.9034894, 0.928397, 1.0], + [-1.1259035, 0.92528087, 1.0], + [-3.3147051, 0.8988185, 1.0], + [0.0, 1.0, 1.0], + [4.153484, 0.91500145, 1.0], + [0.48695508, 0.940206, 1.0], + [-2.1672661, 0.9259778, 1.0], + [-3.7963305, 0.88363916, 1.0], + [0.0, 1.0, 1.0], + [5.7889557, 0.9511915, 1.0], + [2.4093935, 0.9637889, 1.0], + [-0.28017357, 0.9176229, 1.0], + [-1.7246062, 0.86704445, 1.0], + [0.0, 1.0, 1.0], + [6.9804792, 1.0035709, 1.0], + [4.4882298, 0.9964915, 1.0], + [2.0359013, 0.9096808, 1.0], + [0.44025534, 0.8560936, 1.0], + [0.0, 1.0, 1.0], + [7.1492724, 1.0114399, 1.0], + [5.4943357, 1.0019382, 1.0], + [4.05172, 0.8888965, 1.0], + [2.2645717, 0.7902095, 1.0], + [0.0, 1.0, 1.0], + [6.758939, 0.99153984, 1.0], + [5.431977, 0.95972574, 1.0], + [4.6545696, 0.84344786, 1.0], + [3.4948368, 0.77209806, 1.0], + [0.0, 1.0, 1.0], + [5.388168, 0.986505, 1.0], + [4.070344, 0.92283, 1.0], + [3.5232341, 0.83252263, 1.0], + [3.0174634, 0.7662801, 1.0], + [0.0, 1.0, 1.0], + [3.7689843, 0.96089333, 1.0], + [2.5116751, 0.8785204, 1.0], + [2.3376532, 0.8223458, 1.0], + [2.414712, 0.7608956, 1.0], + [0.0, 1.0, 1.0], + [3.2908578, 0.9508312, 1.0], + [2.2224586, 0.88289684, 1.0], + [2.228365, 0.8166788, 1.0], + [2.6414301, 0.7484262, 1.0], + [0.0, 1.0, 1.0], + [3.3916478, 0.93619686, 1.0], + [2.823667, 0.87227386, 1.0], + [3.0609424, 0.79408795, 1.0], + [3.626718, 0.72579795, 1.0], + [0.0, 1.0, 1.0], + [3.1597862, 0.9192608, 1.0], + [3.008894, 0.85837466, 1.0], + [3.3871465, 0.79258764, 1.0], + [3.9049165, 0.7342933, 1.0], + [0.0, 1.0, 1.0], + [2.9172287, 0.9121524, 1.0], + [2.6648562, 0.85023427, 1.0], + [2.947294, 0.8032925, 1.0], + [3.3702457, 0.76370627, 1.0], + [0.0, 1.0, 1.0], + [2.5077455, 0.9239658, 1.0], + [2.4199598, 0.87722534, 1.0], + [2.618067, 0.84640473, 1.0], + [2.995166, 0.8136889, 1.0], + [0.0, 1.0, 1.0], + [1.9437534, 0.9129008, 1.0], + [1.9712167, 0.87672514, 1.0], + [2.1036656, 0.8706977, 1.0], + [2.0733418, 0.87078476, 1.0], + [0.0, 1.0, 1.0], + [1.4246463, 0.91300803, 1.0], + [1.1752675, 0.8862555, 1.0], + [0.9968104, 0.90140474, 1.0], + [0.23840506, 0.97830564, 1.0], + [0.0, 1.0, 1.0], + [0.67706054, 0.9196341, 1.0], + [0.109584466, 0.9024811, 1.0], + [-0.27386528, 0.9419742, 1.0], + [-0.059076753, 0.9978306, 1.0], + [0.0, 1.0, 1.0], + [0.07224172, 0.93229127, 1.0], + [-0.34362468, 0.914439, 1.0], + [-0.17629111, 0.96382296, 1.0], + [-0.011366743, 0.9988268, 1.0], + [0.0, 1.0, 1.0], + [-0.81656486, 0.955494, 1.0], + [-1.0913409, 0.94884336, 1.0], + [-0.44080573, 0.982739, 1.0], + [-0.030075505, 0.9989954, 1.0], + [0.0, 1.0, 1.0], + [-1.3378361, 0.9686959, 1.0], + [-1.5182222, 0.96549654, 1.0], + [-0.4606443, 0.9918533, 1.0], + [-0.019811057, 0.9994661, 1.0], + [0.0, 1.0, 1.0], + [-2.243623, 0.95517683, 1.0], + [-2.0646274, 0.93799746, 1.0], + [-0.90104544, 0.95197743, 1.0], + [-0.10999995, 0.99141395, 1.0], + [0.0, 1.0, 1.0], + [-2.8076265, 0.9409817, 1.0], + [-2.058747, 0.9154531, 1.0], + [-1.5804225, 0.88738924, 1.0], + [-0.23936853, 0.9801497, 1.0], + [0.0, 1.0, 1.0], + [-3.9064386, 0.95459986, 1.0], + [-2.3988228, 0.896239, 1.0], + [-1.7098186, 0.8685506, 1.0], + [-0.25361952, 0.9790636, 1.0], + [0.0, 1.0, 1.0], + [-4.2093053, 0.92354167, 1.0], + [-2.2733746, 0.9118433, 1.0], + [0.16433056, 0.9224389, 1.0], + [0.32686478, 0.98159236, 1.0], + [0.0, 1.0, 1.0], + [-4.749688, 0.9219642, 1.0], + [-1.7706362, 0.91556644, 1.0], + [1.4995979, 0.9179761, 1.0], + [0.58829814, 0.9714674, 1.0], + [0.0, 1.0, 1.0], + [-3.9969661, 0.9133036, 1.0], + [-1.1233542, 0.9314691, 1.0], + [0.91808987, 0.9250387, 1.0], + [0.36256704, 0.96524554, 1.0], + [0.0, 1.0, 1.0], + [-2.4866922, 0.91120887, 1.0], + [-0.59065604, 0.9415378, 1.0], + [-0.78037834, 0.9368204, 1.0], + [-0.3913863, 0.9704481, 1.0], + [0.0, 1.0, 1.0], + [-2.3026705, 0.90438557, 1.0], + [-0.8718288, 0.94404954, 1.0], + [-0.15336794, 0.977961, 1.0], + [-0.09629151, 0.9925991, 1.0], + [0.0, 1.0, 1.0], + [-1.9070312, 0.90280366, 1.0], + [-0.55553955, 0.935856, 1.0], + [0.25413927, 0.988254, 1.0], + [0.009861473, 0.9995575, 1.0], + [0.0, 1.0, 1.0], + [-1.9049702, 0.9080516, 1.0], + [-0.266718, 0.9349882, 1.0], + [0.13215524, 0.9933759, 1.0], + [0.0041504498, 0.99984777, 1.0], + [0.0, 1.0, 1.0], + [-0.8701549, 0.90498465, 1.0], + [1.0092131, 0.8918811, 1.0], + [0.3660149, 0.9696158, 1.0], + [0.0074352697, 0.9991519, 1.0], + [0.0, 1.0, 1.0], + [-1.0172404, 0.8942851, 1.0], + [0.6829563, 0.8349779, 1.0], + [0.48213816, 0.91718537, 1.0], + [0.016518895, 0.99731606, 1.0], + [0.0, 1.0, 1.0], + [-1.7463626, 0.8773616, 1.0], + [-0.36728278, 0.80656576, 1.0], + [-0.05267751, 0.8861593, 1.0], + [-0.0062499205, 0.99655586, 1.0], + [0.0, 1.0, 1.0], + [-2.645922, 0.85724914, 1.0], + [-1.6585516, 0.8101157, 1.0], + [-0.77995527, 0.8782376, 1.0], + [-0.014658492, 0.99659693, 1.0], + [0.0, 1.0, 1.0], + [-3.6578364, 0.8668074, 1.0], + [-2.0196826, 0.8535868, 1.0], + [-0.37624604, 0.921359, 1.0], + [0.00017735251, 0.99736357, 1.0], + [0.0, 1.0, 1.0], + [-4.0841975, 0.8596372, 1.0], + [-2.4710217, 0.8740145, 1.0], + [-0.1209378, 0.920483, 1.0], + [0.045910586, 0.99135536, 1.0], + [0.0, 1.0, 1.0], + [-4.430148, 0.85118824, 1.0], + [-2.7032092, 0.8731887, 1.0], + [-0.7946189, 0.8594394, 1.0], + [-0.09909849, 0.9567725, 1.0], + [0.0, 1.0, 1.0], + [-4.8029838, 0.8495463, 1.0], + [-2.6373093, 0.8836435, 1.0], + [-1.0274059, 0.83784634, 1.0], + [-0.34142062, 0.8863734, 1.0], + [0.0, 1.0, 1.0], + [-5.0252833, 0.8179336, 1.0], + [-3.0253406, 0.8344459, 1.0], + [-1.3676816, 0.83761764, 1.0], + [0.08377362, 0.8903176, 1.0], + [0.0, 1.0, 1.0], + [-3.5889053, 0.8333059, 1.0], + [-1.7982966, 0.8581034, 1.0], + [-0.067006715, 0.87093455, 1.0], + [1.3765932, 0.9137696, 1.0], + [0.0, 1.0, 1.0], + [-2.6394725, 0.8462031, 1.0], + [-0.5374585, 0.88883096, 1.0], + [1.3434483, 0.91526973, 1.0], + [2.196534, 0.948764, 1.0], + [0.0, 1.0, 1.0], + [-1.8830673, 0.91449887, 1.0], + [0.3436676, 0.948412, 1.0], + [2.4071665, 0.9718761, 1.0], + [3.2197835, 0.9905023, 1.0], + [0.0, 1.0, 1.0], + [-2.1540444, 0.9479222, 1.0], + [0.77035385, 0.9906655, 1.0], + [3.5416546, 1.0201747, 1.0], + [4.4329867, 1.036617, 1.0], + [0.0, 1.0, 1.0], + [-2.6539636, 0.8664964, 1.0], + [0.31695533, 0.9838804, 1.0], + [3.5114965, 1.0515474, 1.0], + [4.3682404, 1.0739245, 1.0], + [0.0, 1.0, 1.0], + [-2.889564, 0.7832875, 1.0], + [-0.47233972, 0.94827306, 1.0], + [1.9158661, 1.0925105, 1.0], + [1.7773509, 1.0825669, 1.0], + [0.0, 1.0, 1.0], + [-2.359935, 0.7418968, 1.0], + [0.13793384, 0.9809765, 1.0], + [1.3180828, 1.1471351, 1.0], + [0.7645996, 1.0599663, 1.0], + [0.0, 1.0, 1.0], + [-2.2418065, 0.7301813, 1.0], + [0.35360616, 0.91816384, 1.0], + [0.9692918, 1.0641508, 1.0], + [0.07664488, 1.006114, 1.0], + [0.0, 1.0, 1.0], + [-2.0775743, 0.7516581, 1.0], + [-0.124465264, 0.8922322, 1.0], + [0.04122935, 0.9999263, 1.0], + [-0.016318215, 1.0011346, 1.0], + [0.0, 1.0, 1.0], + [-1.3793565, 0.7586109, 1.0], + [0.04257845, 0.8988754, 1.0], + [-0.082526416, 0.98730445, 1.0], + [-0.0054138596, 0.9999747, 1.0], + [0.0, 1.0, 1.0], + [-0.505196, 0.7803437, 1.0], + [0.40779415, 0.90821487, 1.0], + [-0.1287433, 1.0026283, 1.0], + [-0.011499701, 1.0009819, 1.0], + [0.0, 1.0, 1.0], + [-0.058046483, 0.78796697, 1.0], + [0.93765765, 0.894442, 1.0], + [0.043037504, 1.004575, 1.0], + [-0.008396565, 1.0010221, 1.0], + [0.0, 1.0, 1.0], + [-0.043535788, 0.8018015, 1.0], + [0.80214524, 0.9042569, 1.0], + [0.15616187, 0.9971092, 1.0], + [-0.00031897958, 1.0001391, 1.0], + [0.0, 1.0, 1.0], + [-0.13094118, 0.81996274, 1.0], + [-0.057957847, 0.9010969, 1.0], + [-0.15578985, 0.9838379, 1.0], + [-0.0028997594, 0.9998916, 1.0], + [0.0, 1.0, 1.0], + [-0.24140343, 0.8421498, 1.0], + [-0.33087662, 0.8944223, 1.0], + [-0.11538279, 0.96010375, 1.0], + [-0.0023777992, 0.99934727, 1.0], + [0.0, 1.0, 1.0], + [-0.6291469, 0.8663977, 1.0], + [-0.93443865, 0.8860551, 1.0], + [-0.52833366, 0.9360109, 1.0], + [-0.013429848, 0.99847513, 1.0], + [0.0, 1.0, 1.0], + [-0.90612704, 0.8846548, 1.0], + [-1.0758501, 0.9156333, 1.0], + [-0.31288663, 0.95635, 1.0], + [-0.0065308507, 0.9988607, 1.0], + [0.0, 1.0, 1.0], + [-0.8505977, 0.8680036, 1.0], + [-0.6228383, 0.9419045, 1.0], + [0.008568136, 0.99068564, 1.0], + [0.00062457524, 0.9997916, 1.0], + [0.0, 1.0, 1.0], + [-1.3649141, 0.8661397, 1.0], + [-1.1351817, 0.9570879, 1.0], + [-0.4343645, 0.99677765, 1.0], + [-0.007229383, 0.9998938, 1.0], + [0.0, 1.0, 1.0], + [-0.3357523, 0.86801755, 1.0], + [-0.53919494, 0.9580416, 1.0], + [-0.3332547, 1.0017875, 1.0], + [0.0020524773, 1.0001712, 1.0], + [0.0, 1.0, 1.0], + [1.0389299, 0.87367016, 1.0], + [1.5910575, 0.95255834, 1.0], + [1.0409293, 0.9986148, 1.0], + [0.06169785, 0.99989563, 1.0], + [0.0, 1.0, 1.0], + [0.69167626, 0.864867, 1.0], + [0.517416, 0.9371735, 1.0], + [0.7297484, 0.9740146, 1.0], + [0.14100057, 0.9978946, 1.0], + [0.0, 1.0, 1.0], + [1.1966501, 0.8564259, 1.0], + [0.7971627, 0.9295377, 1.0], + [0.98976326, 0.9878759, 1.0], + [0.10663256, 0.9997394, 1.0], + [0.0, 1.0, 1.0], + [2.9381518, 0.84076357, 1.0], + [2.7677243, 0.9219802, 1.0], + [4.2827673, 0.9931431, 1.0], + [0.7215513, 1.0000048, 1.0], + [0.0, 1.0, 1.0], + [4.0477877, 0.82670283, 1.0], + [3.4655888, 0.91149926, 1.0], + [5.5720844, 0.97649837, 1.0], + [2.4715145, 0.9948263, 1.0], + [0.0, 1.0, 1.0], + [4.8419666, 0.8251362, 1.0], + [4.0095496, 0.90738267, 1.0], + [5.8746896, 0.9661354, 1.0], + [4.1081214, 0.98024684, 1.0], + [0.0, 1.0, 1.0], + [4.5143023, 0.835298, 1.0], + [3.7496395, 0.9078515, 1.0], + [5.115886, 0.9541289, 1.0], + [3.3494966, 0.97551304, 1.0], + [0.0, 1.0, 1.0], + [4.479901, 0.8648893, 1.0], + [3.820371, 0.9325536, 1.0], + [5.326134, 0.97972375, 1.0], + [4.717001, 1.0019616, 1.0], + [0.0, 1.0, 1.0], + [6.5529094, 0.68653643, 1.0], + [4.249971, 0.8358243, 1.0], + [3.886182, 0.9036481, 1.0], + [3.1052408, 0.93986005, 1.0], + [0.0, 1.0, 1.0], + [6.0453534, 0.69864804, 1.0], + [4.088292, 0.8079499, 1.0], + [4.4767594, 0.8848239, 1.0], + [4.920347, 0.8965313, 1.0], + [0.0, 1.0, 1.0], + [5.8496194, 0.71566397, 1.0], + [4.111261, 0.7927633, 1.0], + [4.182716, 0.8936798, 1.0], + [4.9267282, 0.9027427, 1.0], + [0.0, 1.0, 1.0], + [6.4675403, 0.7260139, 1.0], + [4.864559, 0.7873533, 1.0], + [3.662903, 0.8703417, 1.0], + [3.33834, 0.89759904, 1.0], + [0.0, 1.0, 1.0], + [6.627538, 0.73187256, 1.0], + [4.7362914, 0.7925029, 1.0], + [2.762565, 0.85528374, 1.0], + [1.6968315, 0.8718531, 1.0], + [0.0, 1.0, 1.0], + [6.1378064, 0.74603015, 1.0], + [3.7333937, 0.8095638, 1.0], + [1.7157373, 0.84111804, 1.0], + [0.7313545, 0.84846157, 1.0], + [0.0, 1.0, 1.0], + [5.8278027, 0.73261493, 1.0], + [3.218722, 0.7893107, 1.0], + [1.7261162, 0.8081886, 1.0], + [1.3808451, 0.80620646, 1.0], + [0.0, 1.0, 1.0], + [6.5486107, 0.735842, 1.0], + [3.34487, 0.7877779, 1.0], + [1.9852514, 0.7963513, 1.0], + [3.2250917, 0.7770536, 1.0], + [0.0, 1.0, 1.0], + [8.3918, 0.7492594, 1.0], + [5.8002057, 0.76848954, 1.0], + [3.2627735, 0.7929065, 1.0], + [4.1515646, 0.7706826, 1.0], + [0.0, 1.0, 1.0], + [9.339295, 0.7832515, 1.0], + [7.126709, 0.778491, 1.0], + [3.6896281, 0.8016613, 1.0], + [3.1497123, 0.78488094, 1.0], + [0.0, 1.0, 1.0], + [9.593153, 0.7601979, 1.0], + [7.8867235, 0.7450819, 1.0], + [5.9123063, 0.7422206, 1.0], + [3.8759134, 0.74508727, 1.0], + [0.0, 1.0, 1.0], + [8.585278, 0.7479115, 1.0], + [7.529606, 0.7175019, 1.0], + [6.162009, 0.7064114, 1.0], + [4.4188056, 0.7255969, 1.0], + [0.0, 1.0, 1.0], + [6.858168, 0.7368539, 1.0], + [5.492259, 0.7144793, 1.0], + [4.347751, 0.70099527, 1.0], + [3.5681868, 0.7093534, 1.0], + [0.0, 1.0, 1.0], + [5.2282605, 0.73686594, 1.0], + [3.2710242, 0.70864916, 1.0], + [2.9356287, 0.6760259, 1.0], + [3.0854452, 0.64669275, 1.0], + [0.0, 1.0, 1.0], + [4.4789567, 0.74254066, 1.0], + [2.5880811, 0.7186022, 1.0], + [2.387628, 0.66940564, 1.0], + [2.4265919, 0.6265305, 1.0], + [0.0, 1.0, 1.0], + [4.528232, 0.7247535, 1.0], + [2.7541575, 0.7074213, 1.0], + [2.5300722, 0.6434275, 1.0], + [2.7180758, 0.5980046, 1.0], + [0.0, 1.0, 1.0], + [4.102856, 0.728164, 1.0], + [2.6525943, 0.71301305, 1.0], + [2.7899814, 0.67262053, 1.0], + [3.1614673, 0.6270317, 1.0], + [0.0, 1.0, 1.0], + [3.6348686, 0.73153895, 1.0], + [2.726432, 0.6877879, 1.0], + [2.54031, 0.7097242, 1.0], + [2.7431397, 0.7150978, 1.0], + [0.0, 1.0, 1.0], + [2.9502785, 0.69625, 1.0], + [2.6242392, 0.6294526, 1.0], + [2.2081497, 0.66211057, 1.0], + [1.3193871, 0.82756114, 1.0], + [0.0, 1.0, 1.0], + [2.0414326, 0.6955794, 1.0], + [2.0578754, 0.6632495, 1.0], + [1.7014283, 0.7117629, 1.0], + [0.3407154, 0.9488139, 1.0], + [0.0, 1.0, 1.0], + [1.1289076, 0.7163192, 1.0], + [1.151557, 0.7172398, 1.0], + [0.52380514, 0.86909467, 1.0], + [0.019359209, 0.9961916, 1.0], + [0.0, 1.0, 1.0], + [0.67299646, 0.7355766, 1.0], + [0.51002353, 0.7648463, 1.0], + [0.06944082, 0.96125567, 1.0], + [0.000119109, 0.9998196, 1.0], + [0.0, 1.0, 1.0], + [-0.031164411, 0.77996194, 1.0], + [0.014347508, 0.86290824, 1.0], + [-0.007677018, 0.99158084, 1.0], + [-6.689423e-05, 0.9999727, 1.0], + [0.0, 1.0, 1.0], + [-0.6832901, 0.8222584, 1.0], + [-0.3419607, 0.92960227, 1.0], + [-0.022380346, 0.9983181, 1.0], + [-0.00018521023, 0.9999936, 1.0], + [0.0, 1.0, 1.0], + [-1.0638311, 0.89357316, 1.0], + [-0.2623169, 0.97307545, 1.0], + [-0.008422446, 0.99948406, 1.0], + [-0.00014290158, 0.99999386, 1.0], + [0.0, 1.0, 1.0], + [-1.1319717, 0.89954185, 1.0], + [-0.28591886, 0.9751361, 1.0], + [-0.01649866, 0.9978075, 1.0], + [-0.0016126642, 0.99978495, 1.0], + [0.0, 1.0, 1.0], + [-2.0644026, 0.86974543, 1.0], + [-0.26208007, 0.9613298, 1.0], + [-0.054121625, 0.9739053, 1.0], + [-0.018682964, 0.996442, 1.0], + [0.0, 1.0, 1.0], + [-1.9141648, 0.88052386, 1.0], + [-0.27626938, 0.93415976, 1.0], + [-0.24882263, 0.9263471, 1.0], + [-0.06974276, 0.9880197, 1.0], + [0.0, 1.0, 1.0], + [-2.0521605, 0.8621233, 1.0], + [-0.4830994, 0.962234, 1.0], + [-0.02020153, 0.9847871, 1.0], + [0.019950174, 0.99770623, 1.0], + [0.0, 1.0, 1.0], + [-3.3087368, 0.84192723, 1.0], + [-0.65065837, 0.9700698, 1.0], + [0.11342574, 0.99164987, 1.0], + [0.050568797, 0.99678665, 1.0], + [0.0, 1.0, 1.0], + [-3.7450078, 0.8366232, 1.0], + [-0.49405074, 0.9702762, 1.0], + [0.107314065, 0.9858985, 1.0], + [0.053864308, 0.99334574, 1.0], + [0.0, 1.0, 1.0], + [-2.5509698, 0.8247367, 1.0], + [-0.20142983, 0.96617097, 1.0], + [-0.2560394, 0.981325, 1.0], + [-0.1159319, 0.9916455, 1.0], + [0.0, 1.0, 1.0], + [-1.5854938, 0.81745756, 1.0], + [-0.14268312, 0.97136134, 1.0], + [-0.06509467, 0.9953569, 1.0], + [-0.029058143, 0.9980467, 1.0], + [0.0, 1.0, 1.0], + [-0.8367958, 0.8402703, 1.0], + [-0.10091935, 0.97176695, 1.0], + [-0.0010153912, 0.9995565, 1.0], + [-3.0446154e-05, 0.9999878, 1.0], + [0.0, 1.0, 1.0], + [-2.1198392, 0.84143376, 1.0], + [-0.03249274, 0.952964, 1.0], + [0.012029766, 0.99843806, 1.0], + [8.835511e-05, 0.9999943, 1.0], + [0.0, 1.0, 1.0], + [-1.9262636, 0.86367226, 1.0], + [0.5532922, 0.93086356, 1.0], + [0.09160686, 0.99354273, 1.0], + [0.00081093685, 0.9999354, 1.0], + [0.0, 1.0, 1.0], + [-1.3739588, 0.8016463, 1.0], + [1.2970295, 0.7846802, 1.0], + [0.45966804, 0.9381906, 1.0], + [0.0061520343, 0.99921715, 1.0], + [0.0, 1.0, 1.0], + [-1.4077954, 0.7208454, 1.0], + [0.4733461, 0.6787829, 1.0], + [0.47692603, 0.80988336, 1.0], + [0.012340703, 0.9957405, 1.0], + [0.0, 1.0, 1.0], + [-2.542054, 0.69510156, 1.0], + [-1.0206543, 0.66685224, 1.0], + [-0.3861955, 0.77343684, 1.0], + [-0.0016055064, 0.9925781, 1.0], + [0.0, 1.0, 1.0], + [-4.949766, 0.7236445, 1.0], + [-2.2197113, 0.72735757, 1.0], + [-0.622677, 0.88092417, 1.0], + [-0.015985137, 0.996581, 1.0], + [0.0, 1.0, 1.0], + [-6.6489305, 0.68735474, 1.0], + [-3.5802984, 0.78218496, 1.0], + [-0.5320879, 0.92443025, 1.0], + [-0.022485942, 0.9953605, 1.0], + [0.0, 1.0, 1.0], + [-7.177222, 0.67137307, 1.0], + [-3.1258667, 0.780959, 1.0], + [-1.202351, 0.83195996, 1.0], + [-0.23152742, 0.9466141, 1.0], + [0.0, 1.0, 1.0], + [-7.1036143, 0.67803013, 1.0], + [-3.811027, 0.75741714, 1.0], + [-1.5931911, 0.8057145, 1.0], + [-0.4153525, 0.8942517, 1.0], + [0.0, 1.0, 1.0], + [-6.7684326, 0.6596717, 1.0], + [-5.32512, 0.69404334, 1.0], + [-3.1082761, 0.7684906, 1.0], + [-1.2460512, 0.894522, 1.0], + [0.0, 1.0, 1.0], + [-5.8683157, 0.6405401, 1.0], + [-4.9210896, 0.68566376, 1.0], + [-6.001439, 0.7500565, 1.0], + [-7.3015804, 0.812211, 1.0], + [0.0, 1.0, 1.0], + [-5.0999904, 0.6463381, 1.0], + [-3.7537494, 0.71320873, 1.0], + [-3.3960533, 0.7802192, 1.0], + [-3.351788, 0.85274297, 1.0], + [0.0, 1.0, 1.0], + [-4.22911, 0.68255895, 1.0], + [-2.943262, 0.75258833, 1.0], + [-2.539806, 0.79581076, 1.0], + [-1.4436098, 0.85693127, 1.0], + [0.0, 1.0, 1.0], + [-4.421207, 0.6897405, 1.0], + [-2.546169, 0.7558628, 1.0], + [-0.93006057, 0.8137628, 1.0], + [0.48019657, 0.90172833, 1.0], + [0.0, 1.0, 1.0], + [-4.955394, 0.63199955, 1.0], + [-3.2294798, 0.7054303, 1.0], + [-1.6279404, 0.7905365, 1.0], + [0.42406368, 0.95621544, 1.0], + [0.0, 1.0, 1.0], + [-4.406013, 0.61143196, 1.0], + [-2.4224486, 0.6885289, 1.0], + [0.15226217, 0.94315904, 1.0], + [0.3682204, 1.0079356, 1.0], + [0.0, 1.0, 1.0], + [-2.6070106, 0.59719855, 1.0], + [-0.5420019, 0.6935155, 1.0], + [0.49049014, 0.98263836, 1.0], + [0.093591675, 1.0030893, 1.0], + [0.0, 1.0, 1.0], + [-1.4122118, 0.62467295, 1.0], + [0.61009103, 0.7372408, 1.0], + [0.48575926, 0.9820844, 1.0], + [0.025345104, 1.0000371, 1.0], + [0.0, 1.0, 1.0], + [-0.9076017, 0.68767226, 1.0], + [0.656146, 0.8025899, 1.0], + [0.37165537, 0.9719272, 1.0], + [0.009354364, 0.9996975, 1.0], + [0.0, 1.0, 1.0], + [-0.519386, 0.7165023, 1.0], + [0.67302865, 0.8780051, 1.0], + [0.5275675, 0.94928, 1.0], + [0.022246271, 0.9979151, 1.0], + [0.0, 1.0, 1.0], + [0.09680573, 0.738186, 1.0], + [0.793014, 0.8750658, 1.0], + [0.381764, 0.9613324, 1.0], + [0.009429865, 0.99904037, 1.0], + [0.0, 1.0, 1.0], + [1.8834604, 0.77035594, 1.0], + [1.7601224, 0.8644663, 1.0], + [0.14496735, 0.98939514, 1.0], + [0.00014458288, 0.999937, 1.0], + [0.0, 1.0, 1.0], + [1.8881173, 0.79435354, 1.0], + [2.1193182, 0.87771165, 1.0], + [0.18743913, 0.9930873, 1.0], + [0.00059748656, 0.999982, 1.0], + [0.0, 1.0, 1.0], + [0.8984065, 0.7908348, 1.0], + [0.8870811, 0.86748314, 1.0], + [0.060655933, 0.9830568, 1.0], + [-0.00023914597, 0.9998897, 1.0], + [0.0, 1.0, 1.0], + [-0.052016236, 0.79481345, 1.0], + [-0.2686036, 0.8499417, 1.0], + [-0.24796923, 0.94703746, 1.0], + [-0.00565591, 0.99915326, 1.0], + [0.0, 1.0, 1.0], + [-1.2217782, 0.81121093, 1.0], + [-1.5196391, 0.8464336, 1.0], + [-0.6995659, 0.93161744, 1.0], + [-0.015096771, 0.99854493, 1.0], + [0.0, 1.0, 1.0], + [-1.9182163, 0.7860046, 1.0], + [-1.7838149, 0.8694839, 1.0], + [-0.3889935, 0.966303, 1.0], + [-0.0060358397, 0.99937606, 1.0], + [0.0, 1.0, 1.0], + [-1.4767361, 0.76479936, 1.0], + [-0.9574383, 0.89968514, 1.0], + [-0.093257755, 0.9917394, 1.0], + [-0.0010219052, 0.99989015, 1.0], + [0.0, 1.0, 1.0], + [-1.1596841, 0.762179, 1.0], + [-1.0841764, 0.8807078, 1.0], + [-0.13100374, 0.99240035, 1.0], + [-0.0010621684, 0.9999584, 1.0], + [0.0, 1.0, 1.0], + [-0.6849198, 0.7800176, 1.0], + [-1.0038462, 0.8779829, 1.0], + [-0.09007008, 0.9921065, 1.0], + [-0.0005381258, 0.9999664, 1.0], + [0.0, 1.0, 1.0], + [0.28321254, 0.7977584, 1.0], + [-0.16388126, 0.890086, 1.0], + [0.03192479, 0.9929511, 1.0], + [0.001241174, 0.9999684, 1.0], + [0.0, 1.0, 1.0], + [0.86829543, 0.7936024, 1.0], + [-0.37591735, 0.89833724, 1.0], + [-0.14845619, 0.9868604, 1.0], + [0.00082641013, 0.99980897, 1.0], + [0.0, 1.0, 1.0], + [2.0176198, 0.7752524, 1.0], + [1.6908506, 0.8934624, 1.0], + [0.44986808, 0.9873634, 1.0], + [0.008859725, 0.99985033, 1.0], + [0.0, 1.0, 1.0], + [3.6487887, 0.748154, 1.0], + [2.6644409, 0.8853934, 1.0], + [0.99475205, 0.98551035, 1.0], + [0.050193034, 0.9997457, 1.0], + [0.0, 1.0, 1.0], + [4.480023, 0.7250523, 1.0], + [1.926729, 0.8424514, 1.0], + [0.040736295, 0.92375875, 1.0], + [0.051179513, 0.99580204, 1.0], + [0.0, 1.0, 1.0], + [5.1756477, 0.71742564, 1.0], + [1.3003006, 0.80706257, 1.0], + [-1.707145, 0.8457335, 1.0], + [-0.49415466, 0.96840185, 1.0], + [0.0, 1.0, 1.0], + [4.975246, 0.7181208, 1.0], + [2.2769587, 0.81511086, 1.0], + [-0.6684549, 0.8240774, 1.0], + [-1.1093187, 0.9021424, 1.0], + [0.0, 1.0, 1.0], + [5.6670837, 0.7181022, 1.0], + [3.5531816, 0.83718723, 1.0], + [2.3386168, 0.85608834, 1.0], + [1.0339524, 0.91459143, 1.0], + [0.0, 1.0, 1.0], + [4.161144, 0.74439704, 1.0], + [1.2685431, 0.90642077, 1.0], + [1.2386372, 0.91409504, 1.0], + [0.9295507, 0.9615406, 1.0], + [0.0, 1.0, 1.0], + [4.1777663, 0.7336535, 1.0], + [1.6435673, 0.89623827, 1.0], + [4.6341896, 0.8448458, 1.0], + [5.328556, 0.8743724, 1.0], + [0.0, 1.0, 1.0], + [3.8456101, 0.74945605, 1.0], + [1.7118593, 0.8730719, 1.0], + [4.7893443, 0.8741666, 1.0], + [5.7356167, 0.8831293, 1.0], + [0.0, 1.0, 1.0], + [4.541483, 0.68340665, 1.0], + [3.1775188, 0.750212, 1.0], + [2.7961404, 0.89943826, 1.0], + [2.240247, 0.94229096, 1.0], + [0.0, 1.0, 1.0], + [5.315433, 0.6658273, 1.0], + [4.4101477, 0.6992003, 1.0], + [2.1726768, 0.87961835, 1.0], + [0.8891901, 0.96283895, 1.0], + [0.0, 1.0, 1.0], + [5.594221, 0.6638776, 1.0], + [5.8431273, 0.65800124, 1.0], + [5.264457, 0.7205041, 1.0], + [2.3550959, 0.89265925, 1.0], + [0.0, 1.0, 1.0], + [6.089242, 0.6326394, 1.0], + [7.3321614, 0.59986347, 1.0], + [8.751942, 0.6218272, 1.0], + [7.835094, 0.73740655, 1.0], + [0.0, 1.0, 1.0], + [6.8648624, 0.6189883, 1.0], + [7.854025, 0.5970248, 1.0], + [9.556352, 0.6305344, 1.0], + [9.962623, 0.70967585, 1.0], + [0.0, 1.0, 1.0], + [8.106201, 0.6309747, 1.0], + [8.538228, 0.6175027, 1.0], + [9.568188, 0.6525294, 1.0], + [9.985585, 0.7052805, 1.0], + [0.0, 1.0, 1.0], + [8.281834, 0.64497524, 1.0], + [7.932338, 0.6421112, 1.0], + [6.0639887, 0.72733206, 1.0], + [5.3430767, 0.77500343, 1.0], + [0.0, 1.0, 1.0], + [8.019873, 0.6824282, 1.0], + [7.110651, 0.6669911, 1.0], + [5.331789, 0.7284526, 1.0], + [3.3326175, 0.8244816, 1.0], + [0.0, 1.0, 1.0], + [7.284438, 0.6928456, 1.0], + [7.2835546, 0.6664346, 1.0], + [4.883378, 0.72301525, 1.0], + [1.6401588, 0.8878237, 1.0], + [0.0, 1.0, 1.0], + [5.3979216, 0.7423805, 1.0], + [5.322938, 0.73012125, 1.0], + [2.8791666, 0.7680131, 1.0], + [1.2293904, 0.8762204, 1.0], + [0.0, 1.0, 1.0], + [3.9055657, 0.8100785, 1.0], + [3.3159246, 0.7462504, 1.0], + [2.3902402, 0.6859098, 1.0], + [1.833428, 0.74496704, 1.0], + [0.0, 1.0, 1.0], + [2.3105671, 0.8353257, 1.0], + [1.5842186, 0.76920885, 1.0], + [1.6503354, 0.6779311, 1.0], + [1.5094707, 0.6925992, 1.0], + [0.0, 1.0, 1.0], + [1.6453714, 0.83882755, 1.0], + [1.2651672, 0.7836908, 1.0], + [1.7122288, 0.64048153, 1.0], + [1.8864015, 0.6182712, 1.0], + [0.0, 1.0, 1.0], + [1.4681439, 0.84673464, 1.0], + [1.016748, 0.7926556, 1.0], + [1.5301808, 0.6895232, 1.0], + [1.7087327, 0.6774361, 1.0], + [0.0, 1.0, 1.0], + [1.2655282, 0.8380442, 1.0], + [1.400092, 0.7222438, 1.0], + [0.9317295, 0.751648, 1.0], + [0.38670647, 0.9075669, 1.0], + [0.0, 1.0, 1.0], + [0.99011, 0.8104178, 1.0], + [1.5600725, 0.6673882, 1.0], + [0.8698622, 0.75667554, 1.0], + [0.05312782, 0.9771482, 1.0], + [0.0, 1.0, 1.0], + [0.57059133, 0.84510267, 1.0], + [0.82562035, 0.7819899, 1.0], + [0.26795024, 0.91805077, 1.0], + [0.007893546, 0.9974623, 1.0], + [0.0, 1.0, 1.0], + [-0.025850505, 0.885807, 1.0], + [0.16424465, 0.907354, 1.0], + [0.02480482, 0.9894822, 1.0], + [0.0002904646, 0.9998891, 1.0], + [0.0, 1.0, 1.0], + [0.0051441705, 0.905744, 1.0], + [0.052886356, 0.94857466, 1.0], + [0.003892918, 0.99737555, 1.0], + [1.5296924e-05, 0.9999911, 1.0], + [0.0, 1.0, 1.0], + [-0.13219802, 0.9432007, 1.0], + [0.0067049917, 0.9855628, 1.0], + [0.00048594674, 0.99958736, 1.0], + [1.3410622e-06, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [-0.10593759, 0.96154904, 1.0], + [-0.007101243, 0.9942198, 1.0], + [-5.9125785e-05, 0.99992687, 1.0], + [-1.7132953e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.1380907, 0.98662883, 1.0], + [-0.0150175765, 0.99846256, 1.0], + [-0.00010678602, 0.9999852, 1.0], + [-7.602713e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.13843897, 0.99187297, 1.0], + [-0.014456171, 0.9993438, 1.0], + [-0.00010089983, 0.9999929, 1.0], + [-1.5361991e-06, 0.99999964, 1.0], + [0.0, 1.0, 1.0], + [-0.25314486, 0.9803601, 1.0], + [-0.015702732, 0.9983346, 1.0], + [-0.0008912772, 0.99966294, 1.0], + [-0.00028706418, 0.9999485, 1.0], + [0.0, 1.0, 1.0], + [-0.13319662, 0.9884858, 1.0], + [-0.008045408, 0.9985032, 1.0], + [-0.00546511, 0.9987218, 1.0], + [-0.0013116907, 0.9997901, 1.0], + [0.0, 1.0, 1.0], + [-0.14743957, 0.98442274, 1.0], + [-0.010547765, 0.9988189, 1.0], + [-0.00075808156, 0.99986076, 1.0], + [-8.594952e-05, 0.99998194, 1.0], + [0.0, 1.0, 1.0], + [-0.37524244, 0.9815919, 1.0], + [-0.030857285, 0.9987118, 1.0], + [-7.657946e-06, 0.9999798, 1.0], + [7.531104e-05, 0.9999943, 1.0], + [0.0, 1.0, 1.0], + [-0.30199113, 0.97909504, 1.0], + [-0.019221604, 0.99835676, 1.0], + [0.000142119, 0.99995494, 1.0], + [0.000114501025, 0.99998343, 1.0], + [0.0, 1.0, 1.0], + [-0.07426342, 0.9718809, 1.0], + [0.006918636, 0.99745023, 1.0], + [-0.00086405553, 0.99991477, 1.0], + [-0.0004274567, 0.99996984, 1.0], + [0.0, 1.0, 1.0], + [0.1214249, 0.97155046, 1.0], + [0.020241432, 0.9978329, 1.0], + [-6.842438e-05, 0.9999722, 1.0], + [-0.000104643506, 0.99999297, 1.0], + [0.0, 1.0, 1.0], + [-0.00912883, 0.98002875, 1.0], + [0.009914126, 0.998089, 1.0], + [0.00014658475, 0.99998343, 1.0], + [-3.147069e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.34022465, 0.97413856, 1.0], + [-0.001044419, 0.99583006, 1.0], + [0.0007858895, 0.9998906, 1.0], + [2.2824117e-06, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-0.234471, 0.98301566, 1.0], + [0.012073258, 0.9957116, 1.0], + [0.0027358236, 0.9997176, 1.0], + [1.8471012e-05, 0.9999981, 1.0], + [0.0, 1.0, 1.0], + [-0.37233362, 0.96034855, 1.0], + [0.20355557, 0.9555334, 1.0], + [0.040644884, 0.99291646, 1.0], + [0.00042774918, 0.99992293, 1.0], + [0.0, 1.0, 1.0], + [-0.26744676, 0.88313514, 1.0], + [0.25718036, 0.8152884, 1.0], + [0.09190484, 0.9529967, 1.0], + [0.0019344173, 0.999235, 1.0], + [0.0, 1.0, 1.0], + [-0.92890036, 0.8132875, 1.0], + [-0.62145543, 0.75051594, 1.0], + [-0.07648181, 0.9260143, 1.0], + [0.0015313957, 0.9985089, 1.0], + [0.0, 1.0, 1.0], + [-1.4036704, 0.87832355, 1.0], + [-0.62560636, 0.879744, 1.0], + [-0.07835409, 0.97920424, 1.0], + [-0.001020268, 0.9996515, 1.0], + [0.0, 1.0, 1.0], + [-2.5662296, 0.83067703, 1.0], + [-0.6488212, 0.94305354, 1.0], + [-0.052740045, 0.9922627, 1.0], + [-0.0016239518, 0.9996923, 1.0], + [0.0, 1.0, 1.0], + [-4.7540584, 0.71597546, 1.0], + [-2.4036474, 0.8318091, 1.0], + [-0.7855407, 0.9302143, 1.0], + [-0.030755855, 0.9963339, 1.0], + [0.0, 1.0, 1.0], + [-6.401625, 0.6375166, 1.0], + [-4.2524567, 0.73438454, 1.0], + [-1.7984731, 0.8661054, 1.0], + [-0.076227136, 0.9925712, 1.0], + [0.0, 1.0, 1.0], + [-7.093013, 0.57054, 1.0], + [-7.138467, 0.61704755, 1.0], + [-10.662155, 0.7250832, 1.0], + [-4.8741217, 0.8936352, 1.0], + [0.0, 1.0, 1.0], + [-6.7872076, 0.543695, 1.0], + [-9.125988, 0.5788258, 1.0], + [-17.282015, 0.6463619, 1.0], + [-15.361004, 0.7047228, 1.0], + [0.0, 1.0, 1.0], + [-6.5472207, 0.5460788, 1.0], + [-7.3952637, 0.5873735, 1.0], + [-9.985295, 0.6460461, 1.0], + [-9.715605, 0.75660294, 1.0], + [0.0, 1.0, 1.0], + [-6.143126, 0.5428474, 1.0], + [-6.979643, 0.59978616, 1.0], + [-7.3462987, 0.654454, 1.0], + [-4.6714454, 0.7781302, 1.0], + [0.0, 1.0, 1.0], + [-5.6845136, 0.5430165, 1.0], + [-7.2012515, 0.5718516, 1.0], + [-8.748415, 0.59380674, 1.0], + [-4.6859508, 0.76675576, 1.0], + [0.0, 1.0, 1.0], + [-5.1071506, 0.54315025, 1.0], + [-5.8883495, 0.56850153, 1.0], + [-7.7368093, 0.6292215, 1.0], + [-1.4560128, 0.9221482, 1.0], + [0.0, 1.0, 1.0], + [-2.574775, 0.5935165, 1.0], + [-1.5976609, 0.6629436, 1.0], + [-0.42182118, 0.9376009, 1.0], + [0.0032954898, 0.99905676, 1.0], + [0.0, 1.0, 1.0], + [-0.5040222, 0.66888565, 1.0], + [0.22192046, 0.765007, 1.0], + [0.039829105, 0.97707367, 1.0], + [0.0013656623, 0.99990016, 1.0], + [0.0, 1.0, 1.0], + [0.13229428, 0.750091, 1.0], + [0.26277184, 0.88919055, 1.0], + [0.020233026, 0.99522144, 1.0], + [0.00035138454, 0.99998504, 1.0], + [0.0, 1.0, 1.0], + [0.24901535, 0.8024303, 1.0], + [0.3177278, 0.91495854, 1.0], + [0.051968362, 0.9940321, 1.0], + [0.0007878256, 0.9999375, 1.0], + [0.0, 1.0, 1.0], + [0.40787914, 0.8393718, 1.0], + [0.6778435, 0.91640764, 1.0], + [0.2564198, 0.97801465, 1.0], + [0.0053125066, 0.99956983, 1.0], + [0.0, 1.0, 1.0], + [0.9363007, 0.86411923, 1.0], + [0.7308819, 0.92368954, 1.0], + [0.13012709, 0.9888115, 1.0], + [0.0020543067, 0.99983764, 1.0], + [0.0, 1.0, 1.0], + [1.792059, 0.9070631, 1.0], + [0.8328082, 0.961077, 1.0], + [0.03201244, 0.9983668, 1.0], + [0.00010289253, 0.99999255, 1.0], + [0.0, 1.0, 1.0], + [0.61971796, 0.93544394, 1.0], + [0.28986993, 0.98064864, 1.0], + [0.011807639, 0.99949574, 1.0], + [3.4211585e-05, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [0.36694813, 0.9191711, 1.0], + [0.15008779, 0.9748141, 1.0], + [0.004682179, 0.9991603, 1.0], + [5.24191e-06, 0.9999966, 1.0], + [0.0, 1.0, 1.0], + [-0.04590827, 0.94109243, 1.0], + [-0.035157125, 0.9765446, 1.0], + [-0.007650317, 0.99820185, 1.0], + [-0.00011332178, 0.99998254, 1.0], + [0.0, 1.0, 1.0], + [-0.15313512, 0.97539425, 1.0], + [-0.13065065, 0.9864903, 1.0], + [-0.017957566, 0.9982736, 1.0], + [-0.00023828977, 0.99997765, 1.0], + [0.0, 1.0, 1.0], + [-0.32719183, 0.960239, 1.0], + [-0.13606764, 0.9877723, 1.0], + [-0.007543968, 0.99940944, 1.0], + [-6.793774e-05, 0.9999944, 1.0], + [0.0, 1.0, 1.0], + [-0.33494267, 0.960886, 1.0], + [-0.12677892, 0.9896274, 1.0], + [-0.00804112, 0.9995762, 1.0], + [-5.0966377e-05, 0.99999756, 1.0], + [0.0, 1.0, 1.0], + [-0.56956315, 0.94241995, 1.0], + [-0.83334297, 0.9581462, 1.0], + [-0.09486739, 0.9961979, 1.0], + [-0.00065249624, 0.99997574, 1.0], + [0.0, 1.0, 1.0], + [-0.39292914, 0.943953, 1.0], + [-0.43260995, 0.9667326, 1.0], + [-0.036218695, 0.99800736, 1.0], + [-0.00021563526, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [-0.32144308, 0.95278865, 1.0], + [-0.25995246, 0.9713513, 1.0], + [-0.016442856, 0.9985246, 1.0], + [-9.176571e-05, 0.9999942, 1.0], + [0.0, 1.0, 1.0], + [-0.2966282, 0.9744063, 1.0], + [-0.86246514, 0.9668585, 1.0], + [-0.17140412, 0.9942151, 1.0], + [-0.0021118673, 0.9999324, 1.0], + [0.0, 1.0, 1.0], + [0.08769655, 0.97262937, 1.0], + [-0.25923535, 0.9713818, 1.0], + [-0.06498976, 0.99482614, 1.0], + [-0.00078246964, 0.99993557, 1.0], + [0.0, 1.0, 1.0], + [0.5253878, 0.9505631, 1.0], + [-0.16767718, 0.9614315, 1.0], + [-0.24291219, 0.98733723, 1.0], + [-0.009154632, 0.99959785, 1.0], + [0.0, 1.0, 1.0], + [0.57957804, 0.9173889, 1.0], + [-3.944535, 0.831082, 1.0], + [-2.9686522, 0.8881784, 1.0], + [-0.14177988, 0.9944771, 1.0], + [0.0, 1.0, 1.0], + [0.42895818, 0.90997833, 1.0], + [-5.41419, 0.7882363, 1.0], + [-5.786541, 0.80714077, 1.0], + [-0.8622991, 0.97064185, 1.0], + [0.0, 1.0, 1.0], + [1.3849322, 0.89100575, 1.0], + [-3.418151, 0.8283688, 1.0], + [-7.0915565, 0.766481, 1.0], + [-2.9420247, 0.89867216, 1.0], + [0.0, 1.0, 1.0], + [2.6696517, 0.8438243, 1.0], + [-0.2037993, 0.8984032, 1.0], + [-3.0600338, 0.83977926, 1.0], + [-1.4190359, 0.93247384, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-profiles/NIKON_Z_6_2.json b/assets/camera-profiles/NIKON_Z_6_2.json new file mode 100644 index 0000000000..b00e045c15 --- /dev/null +++ b/assets/camera-profiles/NIKON_Z_6_2.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON Z 6_2", + "files": 296, + "samples": 1099207, + "matrix": [ + [1.182910144251291, -0.29205257488744785, 0.10076600427689364], + [-0.06715969465291372, 1.185204734495203, -0.11541627485450512], + [-0.04243207284624334, -0.8237200432609093, 1.8732041320081247] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [6.2229915, 1.153762, 1.0], + [3.9428349, 1.1697679, 1.0], + [2.9512422, 1.1150318, 1.0], + [1.4521159, 1.0443524, 1.0], + [0.0, 1.0, 1.0], + [6.067863, 1.1530151, 1.0], + [2.674163, 1.1492246, 1.0], + [1.6906267, 1.0755203, 1.0], + [1.0559876, 1.0193381, 1.0], + [0.0, 1.0, 1.0], + [6.406609, 1.1438, 1.0], + [2.0468237, 1.126483, 1.0], + [0.6318166, 1.0328915, 1.0], + [0.38942862, 0.9951044, 1.0], + [0.0, 1.0, 1.0], + [5.6572795, 1.1365504, 1.0], + [1.088494, 1.1316147, 1.0], + [-0.7465848, 1.038763, 1.0], + [-1.7078602, 0.9750448, 1.0], + [0.0, 1.0, 1.0], + [5.6649947, 1.1077542, 1.0], + [0.8204385, 1.1192347, 1.0], + [-1.5888842, 1.0491564, 1.0], + [-3.0090246, 0.96298206, 1.0], + [0.0, 1.0, 1.0], + [5.256699, 1.0831563, 1.0], + [0.6007586, 1.0977919, 1.0], + [-2.570202, 1.0551977, 1.0], + [-5.4068832, 0.9654456, 1.0], + [0.0, 1.0, 1.0], + [4.539097, 1.0591519, 1.0], + [-0.10774251, 1.0788635, 1.0], + [-4.641281, 1.0305448, 1.0], + [-8.283271, 0.93709576, 1.0], + [0.0, 1.0, 1.0], + [4.142809, 1.0373175, 1.0], + [-0.8308222, 1.0613977, 1.0], + [-5.655344, 1.0214658, 1.0], + [-9.607277, 0.93629843, 1.0], + [0.0, 1.0, 1.0], + [3.8500762, 1.0362741, 1.0], + [-1.0345856, 1.0639124, 1.0], + [-5.4233723, 1.0284288, 1.0], + [-9.0873, 0.9531294, 1.0], + [0.0, 1.0, 1.0], + [4.0558853, 1.0385702, 1.0], + [-0.52777964, 1.0754012, 1.0], + [-4.6981053, 1.0426258, 1.0], + [-7.390084, 0.97722876, 1.0], + [0.0, 1.0, 1.0], + [4.439531, 1.0652789, 1.0], + [0.86604506, 1.0861824, 1.0], + [-2.8576968, 1.0505832, 1.0], + [-5.8478637, 0.9746698, 1.0], + [0.0, 1.0, 1.0], + [4.3485656, 1.0967733, 1.0], + [1.9174945, 1.1038177, 1.0], + [0.058721844, 1.0518842, 1.0], + [-1.5929213, 0.9253342, 1.0], + [0.0, 1.0, 1.0], + [3.6591005, 1.1211772, 1.0], + [2.2590137, 1.1025198, 1.0], + [1.1305307, 1.0139761, 1.0], + [-0.1372095, 0.87818533, 1.0], + [0.0, 1.0, 1.0], + [2.6356182, 1.1266937, 1.0], + [1.7848063, 1.0862155, 1.0], + [1.2136673, 0.99129325, 1.0], + [0.49023673, 0.8714401, 1.0], + [0.0, 1.0, 1.0], + [1.4648303, 1.118881, 1.0], + [0.9687011, 1.0753107, 1.0], + [0.7960804, 0.99398947, 1.0], + [0.7395835, 0.8858062, 1.0], + [0.0, 1.0, 1.0], + [0.44883335, 1.1299157, 1.0], + [0.61828953, 1.0732124, 1.0], + [0.9684744, 0.99130034, 1.0], + [1.4130936, 0.9186135, 1.0], + [0.0, 1.0, 1.0], + [0.40853867, 1.140463, 1.0], + [0.99736977, 1.0603918, 1.0], + [1.5362628, 0.9824557, 1.0], + [2.16631, 0.9292637, 1.0], + [0.0, 1.0, 1.0], + [0.20395991, 1.1486486, 1.0], + [0.8272985, 1.0560744, 1.0], + [1.4990557, 0.9908201, 1.0], + [2.295185, 0.93907094, 1.0], + [0.0, 1.0, 1.0], + [-0.57153416, 1.1716017, 1.0], + [0.24325375, 1.0848265, 1.0], + [1.1403633, 1.0200775, 1.0], + [2.1166058, 0.95587236, 1.0], + [0.0, 1.0, 1.0], + [-1.7980459, 1.2015567, 1.0], + [-0.9087, 1.1328977, 1.0], + [0.12065623, 1.0612612, 1.0], + [1.1421617, 0.9739673, 1.0], + [0.0, 1.0, 1.0], + [-3.3530312, 1.227411, 1.0], + [-2.557493, 1.1682405, 1.0], + [-1.6862918, 1.0950687, 1.0], + [-0.6257788, 0.9961956, 1.0], + [0.0, 1.0, 1.0], + [-5.1431966, 1.2525787, 1.0], + [-4.3500066, 1.1942875, 1.0], + [-3.3942263, 1.1264504, 1.0], + [-0.860235, 1.0129987, 1.0], + [0.0, 1.0, 1.0], + [-7.172523, 1.2782786, 1.0], + [-6.2652287, 1.2184532, 1.0], + [-5.2175336, 1.1654094, 1.0], + [-0.90600926, 1.02157, 1.0], + [0.0, 1.0, 1.0], + [-9.301277, 1.3098416, 1.0], + [-8.319625, 1.254189, 1.0], + [-6.29361, 1.1807519, 1.0], + [-0.2820965, 1.0070943, 1.0], + [0.0, 1.0, 1.0], + [-11.833535, 1.3462554, 1.0], + [-10.534334, 1.2848289, 1.0], + [-5.5140915, 1.1255784, 1.0], + [-0.11413354, 1.0005571, 1.0], + [0.0, 1.0, 1.0], + [-13.837044, 1.3168585, 1.0], + [-11.592036, 1.241745, 1.0], + [-3.8057983, 1.0537387, 1.0], + [-0.12250334, 0.9994377, 1.0], + [0.0, 1.0, 1.0], + [-15.302334, 1.2547938, 1.0], + [-12.304744, 1.171094, 1.0], + [-2.6331367, 1.0254496, 1.0], + [-0.02462376, 1.0000972, 1.0], + [0.0, 1.0, 1.0], + [-16.3649, 1.2033144, 1.0], + [-13.840853, 1.121017, 1.0], + [-4.7440042, 1.016522, 1.0], + [-0.09164992, 1.0001389, 1.0], + [0.0, 1.0, 1.0], + [-17.23536, 1.1622376, 1.0], + [-14.924817, 1.0792886, 1.0], + [-6.874241, 1.0002234, 1.0], + [-0.19945428, 0.9995216, 1.0], + [0.0, 1.0, 1.0], + [-17.24167, 1.1120819, 1.0], + [-13.978684, 1.0264746, 1.0], + [-5.8763328, 0.98856574, 1.0], + [-0.13876384, 0.999489, 1.0], + [0.0, 1.0, 1.0], + [-18.172258, 1.0878747, 1.0], + [-15.457481, 1.0080547, 1.0], + [-6.565444, 0.9856226, 1.0], + [-0.13294418, 0.9996134, 1.0], + [0.0, 1.0, 1.0], + [-16.83038, 1.0693645, 1.0], + [-15.625549, 0.9914287, 1.0], + [-7.72421, 0.9804178, 1.0], + [-0.15811352, 0.9995089, 1.0], + [0.0, 1.0, 1.0], + [-16.11028, 1.0584325, 1.0], + [-14.346652, 0.99943095, 1.0], + [-6.310089, 0.9858199, 1.0], + [-0.109850876, 0.9996651, 1.0], + [0.0, 1.0, 1.0], + [-14.929288, 1.0361999, 1.0], + [-12.50551, 0.9834405, 1.0], + [-5.2874904, 0.98251873, 1.0], + [-0.0921156, 0.99959683, 1.0], + [0.0, 1.0, 1.0], + [-14.727734, 1.0360364, 1.0], + [-12.03307, 0.9717191, 1.0], + [-5.2304645, 0.97766334, 1.0], + [-0.07621693, 0.9996011, 1.0], + [0.0, 1.0, 1.0], + [-15.998532, 1.0374138, 1.0], + [-13.555987, 0.9710816, 1.0], + [-6.071474, 0.97758615, 1.0], + [-0.088387944, 0.9996019, 1.0], + [0.0, 1.0, 1.0], + [-16.750172, 1.027443, 1.0], + [-14.493484, 0.9749124, 1.0], + [-5.9644837, 0.9815559, 1.0], + [-0.09535428, 0.9994739, 1.0], + [0.0, 1.0, 1.0], + [-17.231142, 0.9994733, 1.0], + [-14.346948, 0.95981574, 1.0], + [-6.037715, 0.9591468, 1.0], + [-0.38386485, 0.9952976, 1.0], + [0.0, 1.0, 1.0], + [-17.424784, 0.9555782, 1.0], + [-14.198873, 0.9357824, 1.0], + [-6.6123714, 0.94408447, 1.0], + [-0.4532846, 0.9941679, 1.0], + [0.0, 1.0, 1.0], + [-17.279493, 0.90192854, 1.0], + [-13.564478, 0.90127003, 1.0], + [-6.147515, 0.93834627, 1.0], + [-0.2323653, 0.9971496, 1.0], + [0.0, 1.0, 1.0], + [-17.093803, 0.85399765, 1.0], + [-12.756323, 0.87821823, 1.0], + [-6.1378803, 0.92416245, 1.0], + [-0.26953444, 0.9962813, 1.0], + [0.0, 1.0, 1.0], + [-16.701775, 0.8148734, 1.0], + [-11.737119, 0.86213005, 1.0], + [-4.978892, 0.925974, 1.0], + [-0.2315494, 0.9961441, 1.0], + [0.0, 1.0, 1.0], + [-16.116898, 0.79390544, 1.0], + [-11.100497, 0.8544334, 1.0], + [-3.051835, 0.9431366, 1.0], + [-0.095544234, 0.99742615, 1.0], + [0.0, 1.0, 1.0], + [-15.288991, 0.78051233, 1.0], + [-9.944976, 0.8618912, 1.0], + [-2.3829138, 0.9570897, 1.0], + [-0.033786688, 0.9981832, 1.0], + [0.0, 1.0, 1.0], + [-12.088607, 0.799739, 1.0], + [-6.9067097, 0.9043021, 1.0], + [-2.2045915, 0.98223805, 1.0], + [-0.04008953, 0.9986963, 1.0], + [0.0, 1.0, 1.0], + [-8.372293, 0.8433635, 1.0], + [-3.43054, 0.96173584, 1.0], + [-0.43066922, 1.0183202, 1.0], + [0.19982383, 1.0013425, 1.0], + [0.0, 1.0, 1.0], + [-7.0979204, 0.87468654, 1.0], + [-2.605678, 0.9790638, 1.0], + [0.7438621, 1.0240322, 1.0], + [0.90940267, 1.007664, 1.0], + [0.0, 1.0, 1.0], + [-7.001, 0.881765, 1.0], + [-1.34574, 1.0225254, 1.0], + [1.5278748, 1.0623524, 1.0], + [1.7761368, 1.0346187, 1.0], + [0.0, 1.0, 1.0], + [-6.164434, 0.8987546, 1.0], + [-0.2458219, 1.0717543, 1.0], + [1.9966408, 1.1075406, 1.0], + [2.03719, 1.0744351, 1.0], + [0.0, 1.0, 1.0], + [-5.634933, 0.86535674, 1.0], + [-1.0753672, 1.0728083, 1.0], + [1.0631428, 1.1154276, 1.0], + [0.6964589, 1.0430381, 1.0], + [0.0, 1.0, 1.0], + [-2.4912696, 0.92214453, 1.0], + [-0.93249446, 1.0711535, 1.0], + [-0.41212073, 1.0973893, 1.0], + [-0.1942435, 1.0235159, 1.0], + [0.0, 1.0, 1.0], + [-0.8377413, 1.0034848, 1.0], + [-0.45683056, 1.0996051, 1.0], + [-0.7544672, 1.0989084, 1.0], + [-0.3452393, 1.0226208, 1.0], + [0.0, 1.0, 1.0], + [-0.1365077, 1.0087785, 1.0], + [0.0327875, 1.0931748, 1.0], + [0.03409552, 1.0605692, 1.0], + [-0.08136579, 1.0068369, 1.0], + [0.0, 1.0, 1.0], + [1.215217, 0.95414764, 1.0], + [1.6130612, 1.0335056, 1.0], + [0.07605914, 1.0273331, 1.0], + [-0.20885852, 1.003798, 1.0], + [0.0, 1.0, 1.0], + [2.3572938, 0.92316496, 1.0], + [2.6421683, 1.0025958, 1.0], + [0.9645612, 1.0110464, 1.0], + [0.10840102, 1.0010912, 1.0], + [0.0, 1.0, 1.0], + [3.770162, 0.91984165, 1.0], + [3.5076504, 0.99966085, 1.0], + [1.6117474, 1.0087022, 1.0], + [0.47452623, 0.99906003, 1.0], + [0.0, 1.0, 1.0], + [3.279187, 0.9259939, 1.0], + [2.1051095, 1.0003723, 1.0], + [0.7558728, 1.0085037, 1.0], + [0.4204648, 0.999157, 1.0], + [0.0, 1.0, 1.0], + [3.1316261, 0.9648295, 1.0], + [1.2332041, 1.0157939, 1.0], + [0.23060545, 1.0078584, 1.0], + [0.014259016, 1.0006881, 1.0], + [0.0, 1.0, 1.0], + [3.3989656, 0.99194896, 1.0], + [2.2900262, 1.03556, 1.0], + [1.1375499, 1.013088, 1.0], + [0.113812156, 1.0003917, 1.0], + [0.0, 1.0, 1.0], + [4.0414777, 1.0378546, 1.0], + [2.3057628, 1.1105787, 1.0], + [1.6360228, 1.0446037, 1.0], + [0.24664895, 1.0011342, 1.0], + [0.0, 1.0, 1.0], + [4.806446, 1.0598252, 1.0], + [2.9564526, 1.1106843, 1.0], + [0.9111372, 1.0506293, 1.0], + [-0.017489238, 1.0016158, 1.0], + [0.0, 1.0, 1.0], + [4.3138623, 1.0589161, 1.0], + [2.0492713, 1.1007633, 1.0], + [1.2071747, 1.0373629, 1.0], + [0.13990282, 1.0014288, 1.0], + [0.0, 1.0, 1.0], + [4.150446, 1.0142206, 1.0], + [2.3013182, 1.0552701, 1.0], + [1.7414474, 1.0204091, 1.0], + [0.2009622, 1.0014528, 1.0], + [0.0, 1.0, 1.0], + [5.0740504, 0.9837976, 1.0], + [2.0655751, 1.0382036, 1.0], + [0.7234968, 1.018726, 1.0], + [-0.1094017, 1.0020746, 1.0], + [0.0, 1.0, 1.0], + [5.1731286, 0.99559367, 1.0], + [2.5818305, 1.019053, 1.0], + [1.2261839, 1.0031092, 1.0], + [0.17011318, 1.0003996, 1.0], + [0.0, 1.0, 1.0], + [4.2959146, 1.0069213, 1.0], + [1.4468756, 1.0213524, 1.0], + [0.6366564, 1.0068634, 1.0], + [0.06898734, 1.0005128, 1.0], + [0.0, 1.0, 1.0], + [6.0099535, 1.005978, 1.0], + [2.4273057, 1.0169591, 1.0], + [-0.13818303, 1.0067599, 1.0], + [-0.0033633148, 1.0006784, 1.0], + [0.0, 1.0, 1.0], + [7.5230107, 1.0331137, 1.0], + [4.7096148, 1.0529838, 1.0], + [1.6526409, 1.0215665, 1.0], + [0.09062616, 1.0016879, 1.0], + [0.0, 1.0, 1.0], + [8.177528, 1.0500778, 1.0], + [5.78432, 1.0727233, 1.0], + [4.1867056, 1.0556285, 1.0], + [0.946918, 1.0117143, 1.0], + [0.0, 1.0, 1.0], + [7.787752, 1.0514582, 1.0], + [6.581295, 1.0929804, 1.0], + [5.1642475, 1.0660969, 1.0], + [1.3973913, 1.0133713, 1.0], + [0.0, 1.0, 1.0], + [7.352549, 1.0832101, 1.0], + [6.488305, 1.1071424, 1.0], + [4.7150044, 1.0691316, 1.0], + [0.9782577, 1.0079756, 1.0], + [0.0, 1.0, 1.0], + [7.44746, 1.1423436, 1.0], + [5.2333174, 1.1424847, 1.0], + [4.2587075, 1.0928718, 1.0], + [1.1760778, 1.0190315, 1.0], + [0.0, 1.0, 1.0], + [4.7819715, 1.0929513, 1.0], + [3.038743, 1.1225566, 1.0], + [3.0403032, 1.0947937, 1.0], + [2.7168245, 1.0584737, 1.0], + [0.0, 1.0, 1.0], + [4.2442985, 1.0740716, 1.0], + [1.8085717, 1.092445, 1.0], + [1.6521245, 1.0550505, 1.0], + [1.2793007, 1.015843, 1.0], + [0.0, 1.0, 1.0], + [3.8161194, 1.048953, 1.0], + [1.0271565, 1.0645669, 1.0], + [0.37108362, 1.0149966, 1.0], + [0.16207772, 0.98473, 1.0], + [0.0, 1.0, 1.0], + [2.9961936, 1.0367951, 1.0], + [0.3735622, 1.0575669, 1.0], + [-1.3138187, 0.9924495, 1.0], + [-2.2027087, 0.9511259, 1.0], + [0.0, 1.0, 1.0], + [3.2774894, 1.0240017, 1.0], + [0.48561475, 1.0381898, 1.0], + [-1.8494679, 0.98786247, 1.0], + [-3.2887905, 0.9338637, 1.0], + [0.0, 1.0, 1.0], + [3.4378223, 1.0108881, 1.0], + [0.52244306, 1.0189863, 1.0], + [-1.8643115, 0.99323344, 1.0], + [-5.1015615, 0.92938703, 1.0], + [0.0, 1.0, 1.0], + [3.1067812, 0.9985392, 1.0], + [-0.12131784, 1.0145019, 1.0], + [-4.1442766, 0.986398, 1.0], + [-8.339385, 0.9153509, 1.0], + [0.0, 1.0, 1.0], + [2.4298422, 0.96142745, 1.0], + [-1.1118385, 1.0061582, 1.0], + [-5.695069, 0.9836191, 1.0], + [-9.741408, 0.91440827, 1.0], + [0.0, 1.0, 1.0], + [2.01661, 0.9458497, 1.0], + [-1.662954, 0.999966, 1.0], + [-5.8223696, 0.9850643, 1.0], + [-9.232703, 0.9267809, 1.0], + [0.0, 1.0, 1.0], + [2.2962167, 0.9605364, 1.0], + [-1.1373878, 1.0126402, 1.0], + [-4.8479958, 0.99816114, 1.0], + [-7.4450336, 0.9406932, 1.0], + [0.0, 1.0, 1.0], + [3.036456, 0.99115914, 1.0], + [0.17963961, 1.0285513, 1.0], + [-3.3088403, 1.0056642, 1.0], + [-6.273956, 0.93629354, 1.0], + [0.0, 1.0, 1.0], + [3.0664618, 1.026951, 1.0], + [1.2165159, 1.0476551, 1.0], + [-0.42700893, 0.9974062, 1.0], + [-1.4580182, 0.8679089, 1.0], + [0.0, 1.0, 1.0], + [2.7013292, 1.0606134, 1.0], + [1.5602528, 1.0500852, 1.0], + [0.58767015, 0.96606606, 1.0], + [-0.12461443, 0.83817023, 1.0], + [0.0, 1.0, 1.0], + [1.75421, 1.0628642, 1.0], + [1.077012, 1.0321119, 1.0], + [0.81344783, 0.9464788, 1.0], + [0.4725721, 0.8391105, 1.0], + [0.0, 1.0, 1.0], + [0.6471407, 1.0532293, 1.0], + [0.40457213, 1.0171094, 1.0], + [0.61659175, 0.9494374, 1.0], + [0.7804061, 0.8600364, 1.0], + [0.0, 1.0, 1.0], + [-0.15095921, 1.0584109, 1.0], + [0.24909112, 0.999217, 1.0], + [0.7718789, 0.9383637, 1.0], + [1.2283664, 0.8916653, 1.0], + [0.0, 1.0, 1.0], + [-0.23281652, 1.0636709, 1.0], + [0.5478948, 0.98645896, 1.0], + [1.1050255, 0.9356548, 1.0], + [1.721518, 0.89987594, 1.0], + [0.0, 1.0, 1.0], + [-0.45712063, 1.0652657, 1.0], + [0.3815263, 0.98859835, 1.0], + [1.0404433, 0.94592875, 1.0], + [1.8172855, 0.90899503, 1.0], + [0.0, 1.0, 1.0], + [-1.1051946, 1.0864792, 1.0], + [-0.06119209, 1.0147051, 1.0], + [0.7248705, 0.9713246, 1.0], + [1.5626872, 0.92918205, 1.0], + [0.0, 1.0, 1.0], + [-2.237749, 1.127092, 1.0], + [-1.070423, 1.0662243, 1.0], + [-0.104194425, 1.0130713, 1.0], + [0.804536, 0.9474472, 1.0], + [0.0, 1.0, 1.0], + [-3.6953864, 1.1598293, 1.0], + [-2.686826, 1.106682, 1.0], + [-1.7835299, 1.0456588, 1.0], + [-0.8053292, 0.9597566, 1.0], + [0.0, 1.0, 1.0], + [-5.2643313, 1.1904461, 1.0], + [-4.4016128, 1.1447512, 1.0], + [-3.6761749, 1.0970792, 1.0], + [-2.1196303, 0.9963984, 1.0], + [0.0, 1.0, 1.0], + [-7.199227, 1.2223662, 1.0], + [-6.308516, 1.1835574, 1.0], + [-5.5572577, 1.15013, 1.0], + [-2.1184824, 1.0233642, 1.0], + [0.0, 1.0, 1.0], + [-9.357493, 1.2634833, 1.0], + [-8.469821, 1.2300229, 1.0], + [-7.221032, 1.1844004, 1.0], + [-0.7896582, 1.0104114, 1.0], + [0.0, 1.0, 1.0], + [-11.875645, 1.3014976, 1.0], + [-10.859666, 1.2672151, 1.0], + [-7.7761583, 1.1516356, 1.0], + [-0.47165853, 0.9992604, 1.0], + [0.0, 1.0, 1.0], + [-14.108668, 1.2699456, 1.0], + [-12.638117, 1.2220073, 1.0], + [-7.2701273, 1.0625613, 1.0], + [-0.6548264, 0.99727935, 1.0], + [0.0, 1.0, 1.0], + [-15.724136, 1.204452, 1.0], + [-13.639737, 1.1411229, 1.0], + [-5.4015455, 1.0294274, 1.0], + [-0.093046986, 0.99997, 1.0], + [0.0, 1.0, 1.0], + [-16.673388, 1.1604823, 1.0], + [-15.056745, 1.0991567, 1.0], + [-6.999325, 1.0093077, 1.0], + [-0.15357149, 0.99979734, 1.0], + [0.0, 1.0, 1.0], + [-17.081741, 1.1213652, 1.0], + [-15.57136, 1.0569661, 1.0], + [-8.844844, 0.9832494, 1.0], + [-0.32678962, 0.9983225, 1.0], + [0.0, 1.0, 1.0], + [-17.33926, 1.0855446, 1.0], + [-15.313985, 1.0192273, 1.0], + [-8.119184, 0.9764423, 1.0], + [-0.23862769, 0.9987188, 1.0], + [0.0, 1.0, 1.0], + [-18.433193, 1.0620961, 1.0], + [-16.90102, 0.9942711, 1.0], + [-9.617896, 0.9711072, 1.0], + [-0.2552796, 0.99904865, 1.0], + [0.0, 1.0, 1.0], + [-17.276731, 1.0457346, 1.0], + [-16.739477, 0.9782363, 1.0], + [-11.156491, 0.96119857, 1.0], + [-0.34167433, 0.9984216, 1.0], + [0.0, 1.0, 1.0], + [-17.084091, 1.0438286, 1.0], + [-15.816032, 0.984013, 1.0], + [-9.323119, 0.9641214, 1.0], + [-0.2387637, 0.9986463, 1.0], + [0.0, 1.0, 1.0], + [-15.596954, 1.0198183, 1.0], + [-13.655594, 0.9686297, 1.0], + [-7.797318, 0.955375, 1.0], + [-0.22518365, 0.9980864, 1.0], + [0.0, 1.0, 1.0], + [-15.301703, 1.0141279, 1.0], + [-12.989882, 0.93955874, 1.0], + [-7.9458184, 0.9391862, 1.0], + [-0.2038498, 0.9980101, 1.0], + [0.0, 1.0, 1.0], + [-16.792349, 1.0134387, 1.0], + [-14.75936, 0.9455252, 1.0], + [-8.760609, 0.9422921, 1.0], + [-0.30783197, 0.9958334, 1.0], + [0.0, 1.0, 1.0], + [-17.005386, 1.0050753, 1.0], + [-15.469527, 0.9674358, 1.0], + [-8.49741, 0.95576, 1.0], + [-0.41260514, 0.9953345, 1.0], + [0.0, 1.0, 1.0], + [-17.273808, 0.9787281, 1.0], + [-15.69969, 0.942322, 1.0], + [-12.234762, 0.87553686, 1.0], + [-2.3212836, 0.9701262, 1.0], + [0.0, 1.0, 1.0], + [-17.132599, 0.9134147, 1.0], + [-15.41619, 0.8991917, 1.0], + [-12.281813, 0.8682994, 1.0], + [-2.1571953, 0.970933, 1.0], + [0.0, 1.0, 1.0], + [-16.799068, 0.85166913, 1.0], + [-14.772821, 0.8631338, 1.0], + [-9.500277, 0.8921043, 1.0], + [-0.7092388, 0.98975027, 1.0], + [0.0, 1.0, 1.0], + [-16.442728, 0.8058451, 1.0], + [-13.375286, 0.8466388, 1.0], + [-8.537738, 0.8891218, 1.0], + [-0.5375653, 0.99202126, 1.0], + [0.0, 1.0, 1.0], + [-15.213923, 0.78132874, 1.0], + [-11.185756, 0.8387474, 1.0], + [-7.113949, 0.8830028, 1.0], + [-0.51550055, 0.98982215, 1.0], + [0.0, 1.0, 1.0], + [-14.168373, 0.7644356, 1.0], + [-10.289349, 0.830659, 1.0], + [-4.6552444, 0.8868689, 1.0], + [-0.362867, 0.9811054, 1.0], + [0.0, 1.0, 1.0], + [-12.229682, 0.75506926, 1.0], + [-8.255229, 0.8390729, 1.0], + [-3.1724656, 0.88942176, 1.0], + [-0.36211884, 0.96008885, 1.0], + [0.0, 1.0, 1.0], + [-8.780003, 0.78203535, 1.0], + [-4.8128314, 0.88308793, 1.0], + [-1.7214035, 0.9332362, 1.0], + [0.07604427, 0.9700093, 1.0], + [0.0, 1.0, 1.0], + [-5.1902246, 0.84770036, 1.0], + [-2.0365455, 0.94785786, 1.0], + [0.075043306, 0.98688334, 1.0], + [1.348575, 0.9891258, 1.0], + [0.0, 1.0, 1.0], + [-3.9187067, 0.8880616, 1.0], + [-0.8643225, 0.98719597, 1.0], + [1.677873, 1.0176314, 1.0], + [3.0216281, 1.0148586, 1.0], + [0.0, 1.0, 1.0], + [-3.5886059, 0.8924353, 1.0], + [0.30670318, 1.0308015, 1.0], + [2.7104156, 1.0678495, 1.0], + [3.735997, 1.0629262, 1.0], + [0.0, 1.0, 1.0], + [-3.084609, 0.91431457, 1.0], + [1.0545353, 1.082462, 1.0], + [2.8006148, 1.1123718, 1.0], + [3.2798169, 1.1044176, 1.0], + [0.0, 1.0, 1.0], + [-3.0868382, 0.88934803, 1.0], + [0.24395227, 1.0737534, 1.0], + [1.8516512, 1.1117501, 1.0], + [1.8256167, 1.0846347, 1.0], + [0.0, 1.0, 1.0], + [-1.303041, 0.91892815, 1.0], + [-0.3339426, 1.0717436, 1.0], + [-0.5027457, 1.1226003, 1.0], + [-0.55369043, 1.0714196, 1.0], + [0.0, 1.0, 1.0], + [0.009014121, 0.99058026, 1.0], + [-0.30335096, 1.1036884, 1.0], + [-1.4451755, 1.1425995, 1.0], + [-1.269934, 1.0913438, 1.0], + [0.0, 1.0, 1.0], + [0.61342883, 0.9911708, 1.0], + [-0.14296348, 1.1055524, 1.0], + [-1.336663, 1.1335871, 1.0], + [-1.1314107, 1.0589578, 1.0], + [0.0, 1.0, 1.0], + [1.7491869, 0.94608873, 1.0], + [1.4192203, 1.0620985, 1.0], + [-0.24427313, 1.079302, 1.0], + [-1.2224454, 1.0283191, 1.0], + [0.0, 1.0, 1.0], + [3.2000449, 0.9136502, 1.0], + [3.1475732, 1.0253253, 1.0], + [2.1547606, 1.0377923, 1.0], + [0.30383298, 1.0079229, 1.0], + [0.0, 1.0, 1.0], + [4.7772436, 0.9144093, 1.0], + [4.162533, 1.047513, 1.0], + [4.2758074, 1.0457861, 1.0], + [3.105151, 0.9908633, 1.0], + [0.0, 1.0, 1.0], + [3.676512, 0.9309974, 1.0], + [2.7957842, 1.0558958, 1.0], + [3.4883666, 1.0355694, 1.0], + [3.137294, 0.9896551, 1.0], + [0.0, 1.0, 1.0], + [3.122498, 0.9589433, 1.0], + [1.543551, 1.0560359, 1.0], + [1.3497539, 1.0383536, 1.0], + [0.11099989, 1.0035237, 1.0], + [0.0, 1.0, 1.0], + [3.580221, 0.9780591, 1.0], + [1.9945487, 1.0655055, 1.0], + [2.4221385, 1.0402309, 1.0], + [0.4563281, 1.0015888, 1.0], + [0.0, 1.0, 1.0], + [4.7698708, 1.0119603, 1.0], + [2.4278777, 1.1139587, 1.0], + [3.0077763, 1.075531, 1.0], + [0.89499116, 1.0035012, 1.0], + [0.0, 1.0, 1.0], + [5.788685, 1.0530056, 1.0], + [2.6037643, 1.1413025, 1.0], + [1.4215078, 1.0968238, 1.0], + [0.20484339, 1.0055182, 1.0], + [0.0, 1.0, 1.0], + [5.1022477, 1.0453085, 1.0], + [2.2648408, 1.1255327, 1.0], + [3.5084808, 1.0749761, 1.0], + [0.8860151, 1.0050836, 1.0], + [0.0, 1.0, 1.0], + [4.840788, 0.99767727, 1.0], + [2.867805, 1.0597396, 1.0], + [5.0557113, 1.041923, 1.0], + [1.229561, 1.0051966, 1.0], + [0.0, 1.0, 1.0], + [7.0307097, 0.97708684, 1.0], + [4.806399, 1.047505, 1.0], + [1.4926684, 1.0392994, 1.0], + [-0.316868, 1.0058483, 1.0], + [0.0, 1.0, 1.0], + [6.4268966, 0.97928965, 1.0], + [4.44971, 1.0286714, 1.0], + [3.9176347, 1.0229295, 1.0], + [0.89289904, 1.0034719, 1.0], + [0.0, 1.0, 1.0], + [5.0285277, 0.9728169, 1.0], + [2.574475, 1.0172137, 1.0], + [3.1116145, 1.0253292, 1.0], + [0.8804246, 1.0046561, 1.0], + [0.0, 1.0, 1.0], + [5.9326806, 0.9673203, 1.0], + [3.0191274, 0.9986364, 1.0], + [2.0088856, 1.0064441, 1.0], + [0.8739533, 1.0035771, 1.0], + [0.0, 1.0, 1.0], + [6.88084, 0.9650284, 1.0], + [3.5343342, 0.9909499, 1.0], + [2.52684, 0.99866265, 1.0], + [1.0010276, 1.0015429, 1.0], + [0.0, 1.0, 1.0], + [6.6674595, 0.9901648, 1.0], + [3.5274854, 1.0219513, 1.0], + [4.0995092, 1.0413644, 1.0], + [2.9521806, 1.0259401, 1.0], + [0.0, 1.0, 1.0], + [7.1322083, 1.0172622, 1.0], + [4.796819, 1.0498525, 1.0], + [4.7010384, 1.0518665, 1.0], + [3.3101153, 1.0278659, 1.0], + [0.0, 1.0, 1.0], + [6.23181, 1.0341, 1.0], + [4.575992, 1.0661216, 1.0], + [4.1811743, 1.0555745, 1.0], + [2.9168804, 1.0209843, 1.0], + [0.0, 1.0, 1.0], + [5.773051, 1.0802647, 1.0], + [3.930077, 1.1002141, 1.0], + [3.9242337, 1.0825543, 1.0], + [3.3296824, 1.0521122, 1.0], + [0.0, 1.0, 1.0], + [2.4754193, 0.95264477, 1.0], + [1.6237425, 0.9976076, 1.0], + [1.6940635, 1.0124187, 1.0], + [1.4032439, 1.0027901, 1.0], + [0.0, 1.0, 1.0], + [1.8328916, 0.9358476, 1.0], + [0.5054257, 0.9516976, 1.0], + [0.82207125, 0.96641874, 1.0], + [0.867466, 0.96557885, 1.0], + [0.0, 1.0, 1.0], + [1.107456, 0.9178314, 1.0], + [-0.11612688, 0.9198909, 1.0], + [-0.13824676, 0.9118656, 1.0], + [-0.0028314982, 0.9240737, 1.0], + [0.0, 1.0, 1.0], + [1.2662044, 0.9126919, 1.0], + [0.20858723, 0.91308266, 1.0], + [-0.74156207, 0.8892692, 1.0], + [-0.96805483, 0.8813321, 1.0], + [0.0, 1.0, 1.0], + [1.5846848, 0.8996693, 1.0], + [0.3940221, 0.9022535, 1.0], + [-1.0160103, 0.8844732, 1.0], + [-1.9547969, 0.869879, 1.0], + [0.0, 1.0, 1.0], + [1.8889635, 0.8861786, 1.0], + [0.46730384, 0.8902896, 1.0], + [-0.8314566, 0.8740593, 1.0], + [-3.0556507, 0.85314214, 1.0], + [0.0, 1.0, 1.0], + [1.786228, 0.87517726, 1.0], + [-0.25766176, 0.8843216, 1.0], + [-2.6286218, 0.87064177, 1.0], + [-6.709825, 0.8475933, 1.0], + [0.0, 1.0, 1.0], + [1.1630952, 0.8485678, 1.0], + [-1.315835, 0.88361984, 1.0], + [-4.564759, 0.8761655, 1.0], + [-7.6052637, 0.84420526, 1.0], + [0.0, 1.0, 1.0], + [0.65808207, 0.8359365, 1.0], + [-1.8502634, 0.871772, 1.0], + [-4.9014773, 0.88665104, 1.0], + [-6.9419703, 0.85948473, 1.0], + [0.0, 1.0, 1.0], + [0.98892176, 0.85553217, 1.0], + [-1.2973236, 0.88800645, 1.0], + [-3.9453318, 0.8810778, 1.0], + [-5.0194716, 0.83465356, 1.0], + [0.0, 1.0, 1.0], + [1.4077076, 0.8883648, 1.0], + [-0.20262001, 0.89855146, 1.0], + [-2.0743167, 0.87382066, 1.0], + [-2.846246, 0.8157335, 1.0], + [0.0, 1.0, 1.0], + [1.6395962, 0.92741174, 1.0], + [0.7942348, 0.92847836, 1.0], + [-0.10936606, 0.878342, 1.0], + [-0.28163967, 0.78697026, 1.0], + [0.0, 1.0, 1.0], + [1.9163837, 0.9670593, 1.0], + [1.1606812, 0.94956505, 1.0], + [0.48494318, 0.86900467, 1.0], + [0.48800853, 0.77839893, 1.0], + [0.0, 1.0, 1.0], + [0.9139721, 0.9643932, 1.0], + [0.70910484, 0.9468291, 1.0], + [0.70515186, 0.87377256, 1.0], + [0.7435633, 0.7895271, 1.0], + [0.0, 1.0, 1.0], + [-0.021806521, 0.9712936, 1.0], + [0.09517821, 0.94683015, 1.0], + [0.48132607, 0.8953816, 1.0], + [0.7116937, 0.82242763, 1.0], + [0.0, 1.0, 1.0], + [-0.79750377, 0.9793866, 1.0], + [-0.08199019, 0.933896, 1.0], + [0.54560524, 0.8938053, 1.0], + [0.91736573, 0.8542098, 1.0], + [0.0, 1.0, 1.0], + [-0.93426365, 0.9845968, 1.0], + [0.15814182, 0.92910755, 1.0], + [0.72163683, 0.89656097, 1.0], + [1.2182864, 0.86374855, 1.0], + [0.0, 1.0, 1.0], + [-1.0705345, 0.98736614, 1.0], + [-0.011547462, 0.9383716, 1.0], + [0.6337991, 0.90952563, 1.0], + [1.2993928, 0.87798977, 1.0], + [0.0, 1.0, 1.0], + [-1.6194162, 1.0018165, 1.0], + [-0.49097732, 0.9606046, 1.0], + [0.2521988, 0.9307922, 1.0], + [0.9645536, 0.8885482, 1.0], + [0.0, 1.0, 1.0], + [-2.80128, 1.0367984, 1.0], + [-1.5576428, 0.9923525, 1.0], + [-0.66330856, 0.9425935, 1.0], + [0.1666689, 0.83448035, 1.0], + [0.0, 1.0, 1.0], + [-4.1835513, 1.0688453, 1.0], + [-3.0496554, 1.0094827, 1.0], + [-2.0194643, 0.9350747, 1.0], + [-0.84885937, 0.81810623, 1.0], + [0.0, 1.0, 1.0], + [-5.472717, 1.096382, 1.0], + [-4.410626, 1.0342624, 1.0], + [-3.3867917, 0.9620032, 1.0], + [-1.1987106, 0.92667925, 1.0], + [0.0, 1.0, 1.0], + [-7.2029138, 1.1229113, 1.0], + [-5.953019, 1.0709114, 1.0], + [-3.932608, 1.0097591, 1.0], + [-0.7465921, 0.9776096, 1.0], + [0.0, 1.0, 1.0], + [-8.683279, 1.1355549, 1.0], + [-7.128312, 1.0855685, 1.0], + [-3.1864233, 1.005719, 1.0], + [-0.26363567, 0.99284154, 1.0], + [0.0, 1.0, 1.0], + [-10.163454, 1.1477863, 1.0], + [-7.8144236, 1.0853112, 1.0], + [-1.8801234, 1.0063028, 1.0], + [-0.08024162, 0.9992318, 1.0], + [0.0, 1.0, 1.0], + [-12.115101, 1.1146804, 1.0], + [-8.96621, 1.0459248, 1.0], + [-1.6161847, 0.9987317, 1.0], + [-0.071704574, 0.99979985, 1.0], + [0.0, 1.0, 1.0], + [-13.706088, 1.0729254, 1.0], + [-8.66254, 1.008506, 1.0], + [-1.0726118, 0.99519026, 1.0], + [-0.013879563, 0.999924, 1.0], + [0.0, 1.0, 1.0], + [-13.639081, 1.0313063, 1.0], + [-8.5867815, 0.9870387, 1.0], + [-1.1318198, 0.9935133, 1.0], + [-0.014718597, 0.99989676, 1.0], + [0.0, 1.0, 1.0], + [-13.590664, 1.0171928, 1.0], + [-7.5184603, 0.98305726, 1.0], + [-0.9713564, 0.9937742, 1.0], + [-0.017022038, 0.9998613, 1.0], + [0.0, 1.0, 1.0], + [-13.854863, 1.0014843, 1.0], + [-7.721969, 0.9806506, 1.0], + [-0.77561605, 0.99587494, 1.0], + [-0.009547515, 0.9999292, 1.0], + [0.0, 1.0, 1.0], + [-14.060616, 0.98336315, 1.0], + [-8.691673, 0.94924486, 1.0], + [-1.2617085, 0.9861989, 1.0], + [-0.017596014, 0.99978256, 1.0], + [0.0, 1.0, 1.0], + [-12.874084, 0.9567539, 1.0], + [-9.545912, 0.9260754, 1.0], + [-2.5305426, 0.97063804, 1.0], + [-0.05151436, 0.99930036, 1.0], + [0.0, 1.0, 1.0], + [-13.672559, 0.9840259, 1.0], + [-8.942783, 0.9562326, 1.0], + [-1.7533314, 0.983495, 1.0], + [-0.031896364, 0.99963516, 1.0], + [0.0, 1.0, 1.0], + [-12.389929, 0.9684237, 1.0], + [-7.423202, 0.9658616, 1.0], + [-1.1646813, 0.9892395, 1.0], + [-0.019982396, 0.9997543, 1.0], + [0.0, 1.0, 1.0], + [-11.921755, 0.9305603, 1.0], + [-8.221304, 0.9161024, 1.0], + [-2.065746, 0.9699598, 1.0], + [-0.05210479, 0.999025, 1.0], + [0.0, 1.0, 1.0], + [-12.236582, 0.92396444, 1.0], + [-8.276435, 0.9221221, 1.0], + [-3.3240492, 0.9396533, 1.0], + [-0.477805, 0.9889453, 1.0], + [0.0, 1.0, 1.0], + [-11.884224, 0.9080763, 1.0], + [-6.9323835, 0.9483917, 1.0], + [-2.8353944, 0.9600339, 1.0], + [-0.33724767, 0.9937283, 1.0], + [0.0, 1.0, 1.0], + [-13.285868, 0.8697298, 1.0], + [-7.293664, 0.9262688, 1.0], + [-2.6733654, 0.9643648, 1.0], + [-0.293086, 0.9961857, 1.0], + [0.0, 1.0, 1.0], + [-14.295206, 0.8048045, 1.0], + [-9.883155, 0.8632042, 1.0], + [-3.227856, 0.95656085, 1.0], + [-0.23363292, 0.9969017, 1.0], + [0.0, 1.0, 1.0], + [-14.586395, 0.75223583, 1.0], + [-11.311147, 0.81547827, 1.0], + [-2.238798, 0.96711767, 1.0], + [-0.07408935, 0.998945, 1.0], + [0.0, 1.0, 1.0], + [-14.474079, 0.7245708, 1.0], + [-11.533241, 0.7828741, 1.0], + [-2.736401, 0.9435411, 1.0], + [-0.044651125, 0.9990903, 1.0], + [0.0, 1.0, 1.0], + [-13.674288, 0.71236974, 1.0], + [-9.81334, 0.7972127, 1.0], + [-3.45957, 0.9197005, 1.0], + [-0.07574147, 0.9975253, 1.0], + [0.0, 1.0, 1.0], + [-12.7195, 0.69227666, 1.0], + [-8.596455, 0.7779914, 1.0], + [-2.3802116, 0.8463873, 1.0], + [-0.35825908, 0.95749724, 1.0], + [0.0, 1.0, 1.0], + [-10.18446, 0.6873084, 1.0], + [-6.0542045, 0.7841001, 1.0], + [-2.1212237, 0.8281544, 1.0], + [-0.7646297, 0.9053229, 1.0], + [0.0, 1.0, 1.0], + [-6.1229663, 0.74606425, 1.0], + [-3.4445412, 0.8351193, 1.0], + [-1.6135985, 0.87599313, 1.0], + [-0.2056776, 0.9368623, 1.0], + [0.0, 1.0, 1.0], + [-3.7523043, 0.8158876, 1.0], + [-1.8315514, 0.894766, 1.0], + [-0.27596512, 0.93012476, 1.0], + [1.0396978, 0.9575369, 1.0], + [0.0, 1.0, 1.0], + [-2.7688127, 0.8445321, 1.0], + [-0.50951934, 0.9420834, 1.0], + [1.4284167, 0.9780485, 1.0], + [2.5512972, 0.9817148, 1.0], + [0.0, 1.0, 1.0], + [-2.1246834, 0.8524671, 1.0], + [0.29334772, 0.96627426, 1.0], + [2.560665, 1.0186502, 1.0], + [3.3895814, 1.0229484, 1.0], + [0.0, 1.0, 1.0], + [-1.3665168, 0.8436846, 1.0], + [1.2660046, 1.012508, 1.0], + [2.7319925, 1.0737249, 1.0], + [3.0757403, 1.0803311, 1.0], + [0.0, 1.0, 1.0], + [-0.5887472, 0.81296784, 1.0], + [0.699834, 0.98561674, 1.0], + [1.4461241, 1.0658891, 1.0], + [1.3923068, 1.0570195, 1.0], + [0.0, 1.0, 1.0], + [1.096663, 0.8274317, 1.0], + [0.40805075, 0.98812795, 1.0], + [-0.045402966, 1.0472325, 1.0], + [-0.06040587, 1.0157863, 1.0], + [0.0, 1.0, 1.0], + [1.9790232, 0.87913215, 1.0], + [0.37345043, 1.0132964, 1.0], + [-0.79287934, 1.0823095, 1.0], + [-0.34175304, 1.0432074, 1.0], + [0.0, 1.0, 1.0], + [2.5627797, 0.87573963, 1.0], + [0.6304921, 1.0118407, 1.0], + [-0.9373828, 1.0851029, 1.0], + [-0.46959534, 1.031097, 1.0], + [0.0, 1.0, 1.0], + [3.7361374, 0.8472772, 1.0], + [1.9630458, 0.98144615, 1.0], + [-0.21733184, 1.0450604, 1.0], + [-0.49014735, 1.0128133, 1.0], + [0.0, 1.0, 1.0], + [4.3971963, 0.8324688, 1.0], + [2.660751, 0.9585303, 1.0], + [0.16532291, 1.0232745, 1.0], + [-0.14636081, 1.0036846, 1.0], + [0.0, 1.0, 1.0], + [5.0258904, 0.8367818, 1.0], + [2.8924615, 0.9749669, 1.0], + [1.7615348, 1.0238429, 1.0], + [0.81233835, 0.9984574, 1.0], + [0.0, 1.0, 1.0], + [5.2452407, 0.85815537, 1.0], + [2.6645274, 0.9761913, 1.0], + [1.4570304, 1.0139804, 1.0], + [1.0984119, 0.997529, 1.0], + [0.0, 1.0, 1.0], + [4.93689, 0.8569606, 1.0], + [3.1328692, 0.9457437, 1.0], + [0.033365987, 1.0082009, 1.0], + [-0.05564574, 1.0016743, 1.0], + [0.0, 1.0, 1.0], + [5.0655975, 0.8770879, 1.0], + [2.174103, 0.9875497, 1.0], + [0.2545323, 1.0086097, 1.0], + [0.022092488, 1.000235, 1.0], + [0.0, 1.0, 1.0], + [4.4488134, 0.9094009, 1.0], + [1.7526729, 1.0061622, 1.0], + [0.42274538, 1.0103294, 1.0], + [0.051791485, 1.0002624, 1.0], + [0.0, 1.0, 1.0], + [5.1548405, 0.95060486, 1.0], + [1.8016412, 1.0232372, 1.0], + [0.20820145, 1.0119417, 1.0], + [0.027733212, 1.0003631, 1.0], + [0.0, 1.0, 1.0], + [5.4379053, 0.94287974, 1.0], + [1.6865833, 1.0055557, 1.0], + [0.5884739, 1.0089288, 1.0], + [0.10115034, 1.0003462, 1.0], + [0.0, 1.0, 1.0], + [3.8398051, 0.90459824, 1.0], + [1.1598605, 0.9777082, 1.0], + [0.77112013, 1.0046481, 1.0], + [0.15015621, 1.0003301, 1.0], + [0.0, 1.0, 1.0], + [5.6665335, 0.91051537, 1.0], + [3.33516, 0.98394734, 1.0], + [0.32077682, 1.0035998, 1.0], + [-0.005901165, 1.00032, 1.0], + [0.0, 1.0, 1.0], + [4.3764243, 0.9138666, 1.0], + [1.7997692, 0.97097856, 1.0], + [0.87327296, 0.9996552, 1.0], + [0.14200021, 1.0003701, 1.0], + [0.0, 1.0, 1.0], + [2.8900363, 0.8881567, 1.0], + [-0.016681926, 0.9412745, 1.0], + [1.2008874, 0.9925353, 1.0], + [0.2819057, 1.0004404, 1.0], + [0.0, 1.0, 1.0], + [3.0338037, 0.8888064, 1.0], + [0.49603212, 0.92353123, 1.0], + [0.344872, 0.9546143, 1.0], + [0.23514952, 0.99573493, 1.0], + [0.0, 1.0, 1.0], + [3.2889633, 0.8941952, 1.0], + [0.12481757, 0.90704453, 1.0], + [-0.89585435, 0.91424674, 1.0], + [-0.24134572, 0.9783999, 1.0], + [0.0, 1.0, 1.0], + [2.7199328, 0.9107902, 1.0], + [0.096942216, 0.9292534, 1.0], + [-0.3109405, 0.9336825, 1.0], + [-0.33917436, 0.96199673, 1.0], + [0.0, 1.0, 1.0], + [3.5014708, 0.9283932, 1.0], + [1.2909425, 0.95882636, 1.0], + [0.26298514, 0.95131576, 1.0], + [-0.69164455, 0.9573067, 1.0], + [0.0, 1.0, 1.0], + [3.5789986, 0.93125904, 1.0], + [2.0657613, 0.9823736, 1.0], + [1.2339572, 0.9823017, 1.0], + [0.035981927, 0.9774156, 1.0], + [0.0, 1.0, 1.0], + [3.2489352, 0.947897, 1.0], + [2.3317747, 1.0011578, 1.0], + [1.9685928, 1.009779, 1.0], + [1.1930741, 1.0035145, 1.0], + [0.0, 1.0, 1.0], + [0.41370514, 0.86071473, 1.0], + [-0.63482434, 0.8487081, 1.0], + [-0.96838796, 0.8932299, 1.0], + [-0.16537139, 0.98455006, 1.0], + [0.0, 1.0, 1.0], + [0.7274317, 0.8435787, 1.0], + [-0.7413707, 0.8246945, 1.0], + [-0.6780908, 0.84962696, 1.0], + [-0.03611998, 0.9278284, 1.0], + [0.0, 1.0, 1.0], + [-0.025755988, 0.7956887, 1.0], + [-0.7080098, 0.7706736, 1.0], + [-0.00667306, 0.80417067, 1.0], + [1.2645549, 0.8267063, 1.0], + [0.0, 1.0, 1.0], + [1.844674, 0.77885765, 1.0], + [1.358191, 0.74993426, 1.0], + [1.8842225, 0.771222, 1.0], + [3.6828177, 0.75069827, 1.0], + [0.0, 1.0, 1.0], + [2.147642, 0.7875774, 1.0], + [1.6485577, 0.7667687, 1.0], + [2.232393, 0.75781333, 1.0], + [3.8045251, 0.73986065, 1.0], + [0.0, 1.0, 1.0], + [2.1638684, 0.78218484, 1.0], + [1.5419188, 0.7789207, 1.0], + [1.5175326, 0.76315284, 1.0], + [2.9210594, 0.7496939, 1.0], + [0.0, 1.0, 1.0], + [1.6404829, 0.76603454, 1.0], + [0.3848709, 0.76939934, 1.0], + [-0.5066958, 0.7645504, 1.0], + [0.97468865, 0.73737776, 1.0], + [0.0, 1.0, 1.0], + [0.65317655, 0.7593511, 1.0], + [-0.66478103, 0.7523295, 1.0], + [0.07674445, 0.7192325, 1.0], + [0.11743816, 0.7180049, 1.0], + [0.0, 1.0, 1.0], + [-0.07553896, 0.75588036, 1.0], + [-0.7599607, 0.7307774, 1.0], + [0.7937273, 0.702063, 1.0], + [1.210313, 0.71042067, 1.0], + [0.0, 1.0, 1.0], + [0.6146166, 0.7519787, 1.0], + [-0.11158234, 0.7340125, 1.0], + [1.8024358, 0.6884776, 1.0], + [2.2258494, 0.6841371, 1.0], + [0.0, 1.0, 1.0], + [0.7284711, 0.81019914, 1.0], + [0.0031727236, 0.7846696, 1.0], + [2.4180965, 0.6961533, 1.0], + [3.2767165, 0.68659955, 1.0], + [0.0, 1.0, 1.0], + [0.7535696, 0.863907, 1.0], + [0.36523274, 0.8374792, 1.0], + [1.2830526, 0.791074, 1.0], + [1.2069559, 0.8067631, 1.0], + [0.0, 1.0, 1.0], + [1.3411092, 0.88778365, 1.0], + [0.7838614, 0.86403745, 1.0], + [1.0240868, 0.81532073, 1.0], + [0.9583133, 0.7903267, 1.0], + [0.0, 1.0, 1.0], + [-0.12238092, 0.85272485, 1.0], + [0.07477853, 0.8615265, 1.0], + [0.6215121, 0.80922437, 1.0], + [0.8200295, 0.7752551, 1.0], + [0.0, 1.0, 1.0], + [-0.82122755, 0.87185943, 1.0], + [-0.2457037, 0.8873393, 1.0], + [0.26640493, 0.85581523, 1.0], + [0.45366037, 0.81068766, 1.0], + [0.0, 1.0, 1.0], + [-1.3295536, 0.9104388, 1.0], + [-0.5824124, 0.87757504, 1.0], + [0.006645972, 0.8408192, 1.0], + [0.28378436, 0.8216209, 1.0], + [0.0, 1.0, 1.0], + [-1.7040519, 0.9045666, 1.0], + [-0.47549373, 0.86758405, 1.0], + [0.053788297, 0.83981687, 1.0], + [0.40476194, 0.81218266, 1.0], + [0.0, 1.0, 1.0], + [-1.5469458, 0.9090261, 1.0], + [-0.5149614, 0.88305897, 1.0], + [0.04461902, 0.8536314, 1.0], + [0.47533098, 0.8176997, 1.0], + [0.0, 1.0, 1.0], + [-1.9928781, 0.9177581, 1.0], + [-1.1592609, 0.8999771, 1.0], + [-0.47069812, 0.8662861, 1.0], + [0.24946758, 0.8074272, 1.0], + [0.0, 1.0, 1.0], + [-2.8105757, 0.9419143, 1.0], + [-1.9706584, 0.9134377, 1.0], + [-1.0593511, 0.8243537, 1.0], + [-0.37524036, 0.77119154, 1.0], + [0.0, 1.0, 1.0], + [-3.1471803, 0.9753915, 1.0], + [-2.3176653, 0.91906554, 1.0], + [-1.3088802, 0.86219937, 1.0], + [-0.58739233, 0.86786795, 1.0], + [0.0, 1.0, 1.0], + [-2.94802, 0.97012186, 1.0], + [-2.1835663, 0.95009077, 1.0], + [-1.0128062, 0.9544606, 1.0], + [-0.15606245, 0.98380494, 1.0], + [0.0, 1.0, 1.0], + [-3.345666, 0.98280185, 1.0], + [-1.4991432, 0.95262855, 1.0], + [-0.4551724, 0.9883049, 1.0], + [-0.03270482, 0.9987078, 1.0], + [0.0, 1.0, 1.0], + [-3.8461266, 0.9927924, 1.0], + [-1.276193, 0.98380685, 1.0], + [-0.1276723, 0.99596465, 1.0], + [-0.006702845, 0.9997529, 1.0], + [0.0, 1.0, 1.0], + [-2.713865, 0.98152876, 1.0], + [-0.75546634, 0.9879916, 1.0], + [-0.049746733, 0.9991844, 1.0], + [-0.0007843447, 0.9999856, 1.0], + [0.0, 1.0, 1.0], + [-3.2784758, 0.98056763, 1.0], + [-1.0423217, 0.98997813, 1.0], + [-0.050068997, 0.9994019, 1.0], + [-0.00034191704, 0.9999971, 1.0], + [0.0, 1.0, 1.0], + [-2.9297116, 0.96755266, 1.0], + [-0.5981317, 0.9923545, 1.0], + [-0.017654736, 0.999798, 1.0], + [-9.46762e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-3.1329167, 0.9680987, 1.0], + [-0.42209783, 0.9959619, 1.0], + [-0.012668612, 0.9998711, 1.0], + [-8.0989346e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-3.681753, 0.96301955, 1.0], + [-0.42579386, 0.9962166, 1.0], + [-0.00814088, 0.9999096, 1.0], + [-4.4159344e-05, 0.9999995, 1.0], + [0.0, 1.0, 1.0], + [-3.2585168, 0.9649381, 1.0], + [-0.30428812, 0.9962269, 1.0], + [-0.005310869, 0.999927, 1.0], + [-2.5505675e-05, 0.99999964, 1.0], + [0.0, 1.0, 1.0], + [-2.2021565, 0.9749354, 1.0], + [-0.3944129, 0.9904369, 1.0], + [-0.025527034, 0.9992587, 1.0], + [-0.00025198492, 0.9999939, 1.0], + [0.0, 1.0, 1.0], + [-1.929304, 0.971067, 1.0], + [-0.45261085, 0.99142736, 1.0], + [-0.05011053, 0.9990594, 1.0], + [-0.0008144145, 0.99998575, 1.0], + [0.0, 1.0, 1.0], + [-2.7514565, 0.9707775, 1.0], + [-0.364937, 0.9964542, 1.0], + [-0.020018931, 0.9997449, 1.0], + [-0.00028382998, 0.9999959, 1.0], + [0.0, 1.0, 1.0], + [-2.7956183, 0.9632996, 1.0], + [-0.42287976, 0.9969505, 1.0], + [-0.013087578, 0.9999144, 1.0], + [-8.082379e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [-2.4113169, 0.9547617, 1.0], + [-0.33804947, 0.9945204, 1.0], + [-0.026301317, 0.9995303, 1.0], + [-0.0012762488, 0.99997115, 1.0], + [0.0, 1.0, 1.0], + [-2.6816075, 0.95678437, 1.0], + [-0.44680336, 0.99332184, 1.0], + [-0.13794503, 0.99693584, 1.0], + [-0.024087088, 0.9994248, 1.0], + [0.0, 1.0, 1.0], + [-3.1641667, 0.9557707, 1.0], + [-0.37171954, 0.99482054, 1.0], + [-0.076554835, 0.99849296, 1.0], + [-0.012065996, 0.9997304, 1.0], + [0.0, 1.0, 1.0], + [-5.1045394, 0.91970074, 1.0], + [-0.6387288, 0.9893497, 1.0], + [-0.016694926, 0.9997221, 1.0], + [-0.0008751128, 0.99998766, 1.0], + [0.0, 1.0, 1.0], + [-6.9304733, 0.85944414, 1.0], + [-1.2397609, 0.9733297, 1.0], + [-0.039723754, 0.99928564, 1.0], + [-0.0009279736, 0.9999874, 1.0], + [0.0, 1.0, 1.0], + [-8.17317, 0.7962485, 1.0], + [-2.5968554, 0.9279736, 1.0], + [-0.06445126, 0.9980414, 1.0], + [-0.00037242688, 0.99999243, 1.0], + [0.0, 1.0, 1.0], + [-10.704245, 0.7364805, 1.0], + [-5.6246877, 0.8459256, 1.0], + [-0.26169717, 0.99058115, 1.0], + [-0.0014548219, 0.9999466, 1.0], + [0.0, 1.0, 1.0], + [-10.675443, 0.68334174, 1.0], + [-4.872382, 0.8478102, 1.0], + [-0.4881562, 0.98677355, 1.0], + [-0.009578875, 0.99971837, 1.0], + [0.0, 1.0, 1.0], + [-10.985089, 0.61614233, 1.0], + [-6.222056, 0.75582373, 1.0], + [-1.2719929, 0.92699444, 1.0], + [-0.07923371, 0.99300826, 1.0], + [0.0, 1.0, 1.0], + [-9.835739, 0.6035176, 1.0], + [-6.378802, 0.7114187, 1.0], + [-2.099836, 0.85796636, 1.0], + [-0.28848997, 0.9751244, 1.0], + [0.0, 1.0, 1.0], + [-6.6503634, 0.67487407, 1.0], + [-5.500912, 0.7487064, 1.0], + [-4.2971406, 0.8258655, 1.0], + [-0.6571109, 0.96886605, 1.0], + [0.0, 1.0, 1.0], + [-4.7830634, 0.73914504, 1.0], + [-4.141989, 0.79455334, 1.0], + [-5.006245, 0.8116664, 1.0], + [-1.3780211, 0.94300467, 1.0], + [0.0, 1.0, 1.0], + [-3.9277482, 0.75783825, 1.0], + [-2.659795, 0.83125865, 1.0], + [-2.5174627, 0.85422933, 1.0], + [-1.3893734, 0.9027223, 1.0], + [0.0, 1.0, 1.0], + [-2.7880778, 0.79374677, 1.0], + [-1.5678242, 0.8652207, 1.0], + [0.2077058, 0.92604816, 1.0], + [0.5667539, 0.94809216, 1.0], + [0.0, 1.0, 1.0], + [-1.7917917, 0.7941517, 1.0], + [-0.65741813, 0.895106, 1.0], + [0.75534123, 0.9829742, 1.0], + [0.4768409, 0.98621637, 1.0], + [0.0, 1.0, 1.0], + [-1.1052668, 0.73317593, 1.0], + [-0.6049394, 0.8575412, 1.0], + [-0.15478949, 0.99293196, 1.0], + [-0.25519162, 0.99671155, 1.0], + [0.0, 1.0, 1.0], + [1.3859553, 0.7583256, 1.0], + [0.3529042, 0.9013461, 1.0], + [-0.09280269, 0.99014723, 1.0], + [-0.19439623, 0.9756913, 1.0], + [0.0, 1.0, 1.0], + [2.662148, 0.78581506, 1.0], + [1.3001701, 0.8985178, 1.0], + [0.46722722, 0.9815502, 1.0], + [0.23440441, 0.98131305, 1.0], + [0.0, 1.0, 1.0], + [2.7134244, 0.7862562, 1.0], + [1.8330814, 0.8773475, 1.0], + [0.75808954, 0.9856246, 1.0], + [0.15871423, 0.9977693, 1.0], + [0.0, 1.0, 1.0], + [3.707434, 0.78545547, 1.0], + [2.0999856, 0.88736033, 1.0], + [0.21350783, 0.9968301, 1.0], + [0.0005532017, 1.0001154, 1.0], + [0.0, 1.0, 1.0], + [4.2265773, 0.7976756, 1.0], + [2.41513, 0.9033128, 1.0], + [0.098965846, 0.99737674, 1.0], + [-0.0019273323, 1.0000342, 1.0], + [0.0, 1.0, 1.0], + [3.586104, 0.8242546, 1.0], + [1.1944336, 0.94521993, 1.0], + [0.018398829, 0.999946, 1.0], + [0.0061938893, 1.00001, 1.0], + [0.0, 1.0, 1.0], + [3.3312273, 0.85412824, 1.0], + [0.8018384, 0.96504784, 1.0], + [-0.038536284, 1.0002738, 1.0], + [0.0041213674, 1.0000454, 1.0], + [0.0, 1.0, 1.0], + [3.5602465, 0.86023563, 1.0], + [0.8536984, 0.97106135, 1.0], + [-0.06325424, 1.0002065, 1.0], + [-0.009787895, 1.0001173, 1.0], + [0.0, 1.0, 1.0], + [3.319309, 0.88970965, 1.0], + [0.4574435, 0.98576313, 1.0], + [0.0016855623, 0.9999831, 1.0], + [-0.000406475, 1.0000062, 1.0], + [0.0, 1.0, 1.0], + [2.0391257, 0.92719066, 1.0], + [-0.11533654, 0.9924606, 1.0], + [-0.022894483, 1.0000801, 1.0], + [-3.8161572e-05, 1.0000013, 1.0], + [0.0, 1.0, 1.0], + [2.221341, 0.9522132, 1.0], + [0.16617404, 0.99605083, 1.0], + [-0.0031928865, 0.999991, 1.0], + [1.2969534e-05, 1.0000004, 1.0], + [0.0, 1.0, 1.0], + [2.2095737, 0.94623184, 1.0], + [0.2084119, 0.9924171, 1.0], + [-0.0017147859, 0.999864, 1.0], + [0.00015271064, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [1.7305284, 0.92747825, 1.0], + [0.26084626, 0.99035066, 1.0], + [0.002197185, 0.9998796, 1.0], + [0.0002519184, 1.0, 1.0], + [0.0, 1.0, 1.0], + [1.83777, 0.9303522, 1.0], + [0.49865678, 0.99259496, 1.0], + [0.010380797, 0.99993306, 1.0], + [1.543077e-05, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.6540057, 0.93718725, 1.0], + [-0.056169994, 0.98687667, 1.0], + [-0.009431662, 0.9991736, 1.0], + [0.00055399764, 0.9999875, 1.0], + [0.0, 1.0, 1.0], + [1.219968, 0.9170335, 1.0], + [-1.04431, 0.9552193, 1.0], + [-0.7334884, 0.9844318, 1.0], + [-0.067632034, 0.9988193, 1.0], + [0.0, 1.0, 1.0], + [1.3329756, 0.9103736, 1.0], + [-4.453365, 0.87807673, 1.0], + [-7.3110423, 0.86146367, 1.0], + [-1.0087034, 0.9803414, 1.0], + [0.0, 1.0, 1.0], + [0.96455824, 0.8981043, 1.0], + [-5.200631, 0.8460088, 1.0], + [-11.297041, 0.7854038, 1.0], + [-2.6635964, 0.9481161, 1.0], + [0.0, 1.0, 1.0], + [0.67527205, 0.9010675, 1.0], + [-6.0442433, 0.8281746, 1.0], + [-12.542688, 0.7473031, 1.0], + [-5.324665, 0.8921913, 1.0], + [0.0, 1.0, 1.0], + [1.2026789, 0.89397043, 1.0], + [-4.445598, 0.84913737, 1.0], + [-11.919539, 0.74464583, 1.0], + [-6.795066, 0.85686606, 1.0], + [0.0, 1.0, 1.0], + [0.6662235, 0.8867006, 1.0], + [-3.937991, 0.83842826, 1.0], + [-13.422844, 0.71948135, 1.0], + [-8.835538, 0.8229743, 1.0], + [0.0, 1.0, 1.0], + [0.7172698, 0.8660123, 1.0], + [-0.94286054, 0.8551379, 1.0], + [-5.286918, 0.83392274, 1.0], + [-2.4993267, 0.9417325, 1.0], + [0.0, 1.0, 1.0], + [0.0017951994, 0.9914302, 1.0], + [-0.06927332, 0.9921463, 1.0], + [-0.057490297, 0.9959994, 1.0], + [-0.008928111, 0.99963653, 1.0], + [0.0, 1.0, 1.0], + [-0.041254885, 0.98150927, 1.0], + [-0.1306019, 0.97009796, 1.0], + [-0.048590906, 0.9860053, 1.0], + [0.0030063766, 0.99852145, 1.0], + [0.0, 1.0, 1.0], + [-0.112703465, 0.95625734, 1.0], + [-0.2643623, 0.9157722, 1.0], + [0.17177379, 0.9519122, 1.0], + [0.89937246, 0.95642823, 1.0], + [0.0, 1.0, 1.0], + [0.29111728, 0.94092214, 1.0], + [0.3868302, 0.90032035, 1.0], + [3.1862643, 0.84815085, 1.0], + [6.1715746, 0.7603379, 1.0], + [0.0, 1.0, 1.0], + [0.538952, 0.9446369, 1.0], + [0.83218, 0.90219474, 1.0], + [3.4132833, 0.844821, 1.0], + [6.4263897, 0.7552901, 1.0], + [0.0, 1.0, 1.0], + [0.90486366, 0.9366256, 1.0], + [1.3550385, 0.9041044, 1.0], + [4.7555075, 0.83666027, 1.0], + [9.834919, 0.7355414, 1.0], + [0.0, 1.0, 1.0], + [0.749637, 0.92996854, 1.0], + [1.2060188, 0.9036133, 1.0], + [10.243389, 0.71855605, 1.0], + [15.236945, 0.6319401, 1.0], + [0.0, 1.0, 1.0], + [0.38263044, 0.9178865, 1.0], + [1.6851017, 0.8404756, 1.0], + [6.062546, 0.71273714, 1.0], + [7.5150943, 0.7055973, 1.0], + [0.0, 1.0, 1.0], + [0.2073275, 0.8944352, 1.0], + [1.694259, 0.7952898, 1.0], + [4.963602, 0.71330947, 1.0], + [4.4856033, 0.76623136, 1.0], + [0.0, 1.0, 1.0], + [0.42934892, 0.91428727, 1.0], + [1.7023742, 0.81033534, 1.0], + [4.653338, 0.67880553, 1.0], + [4.1607633, 0.7217924, 1.0], + [0.0, 1.0, 1.0], + [0.7662586, 0.92551315, 1.0], + [1.281609, 0.8594126, 1.0], + [4.2112913, 0.7075525, 1.0], + [3.2996652, 0.7721644, 1.0], + [0.0, 1.0, 1.0], + [0.36315376, 0.96427274, 1.0], + [0.17142291, 0.9671226, 1.0], + [0.3704626, 0.96229166, 1.0], + [0.16843563, 0.98521745, 1.0], + [0.0, 1.0, 1.0], + [0.14203277, 0.980844, 1.0], + [0.08888949, 0.98635364, 1.0], + [0.08142525, 0.99080217, 1.0], + [0.04689812, 0.99329925, 1.0], + [0.0, 1.0, 1.0], + [-0.041995242, 0.97444767, 1.0], + [-0.015486327, 0.9832361, 1.0], + [0.025345497, 0.9886648, 1.0], + [0.061056096, 0.9832061, 1.0], + [0.0, 1.0, 1.0], + [-0.06349996, 0.9847081, 1.0], + [-0.010158759, 0.993606, 1.0], + [0.0070079323, 0.9965746, 1.0], + [0.012292187, 0.9953988, 1.0], + [0.0, 1.0, 1.0], + [-0.02714656, 0.99495846, 1.0], + [-0.0047707465, 0.995372, 1.0], + [-0.011022978, 0.9921308, 1.0], + [-0.0004924384, 0.99648327, 1.0], + [0.0, 1.0, 1.0], + [-0.065880194, 0.99450415, 1.0], + [-0.041692242, 0.99075663, 1.0], + [-0.022810183, 0.9813757, 1.0], + [0.0066275946, 0.9910071, 1.0], + [0.0, 1.0, 1.0], + [-0.051800754, 0.9933403, 1.0], + [-0.031969808, 0.9909193, 1.0], + [-0.0138383275, 0.98399764, 1.0], + [0.0023257341, 0.99193186, 1.0], + [0.0, 1.0, 1.0], + [-0.064492755, 0.99565995, 1.0], + [-0.056850635, 0.993179, 1.0], + [-0.03568528, 0.9925555, 1.0], + [-0.006500367, 0.9941837, 1.0], + [0.0, 1.0, 1.0], + [-0.076604635, 0.9975212, 1.0], + [-0.04835303, 0.99697053, 1.0], + [-0.05276575, 0.9912819, 1.0], + [-0.048627503, 0.98763776, 1.0], + [0.0, 1.0, 1.0], + [-0.07185075, 0.99944353, 1.0], + [-0.049465895, 0.99844307, 1.0], + [-0.03390795, 0.9961937, 1.0], + [-0.01572451, 0.99718726, 1.0], + [0.0, 1.0, 1.0], + [-0.06696204, 0.99883705, 1.0], + [-0.059485506, 0.99909484, 1.0], + [-0.020033833, 0.99961084, 1.0], + [-0.001215999, 0.9999084, 1.0], + [0.0, 1.0, 1.0], + [-0.06329192, 0.9984661, 1.0], + [-0.030843416, 0.99811184, 1.0], + [-0.010421576, 0.99985075, 1.0], + [-0.000277232, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [-0.09967728, 0.9996282, 1.0], + [-0.024422867, 0.9996509, 1.0], + [-0.0013948235, 0.9999416, 1.0], + [-1.7647946e-05, 0.99999917, 1.0], + [0.0, 1.0, 1.0], + [-0.061887965, 0.9996889, 1.0], + [-0.0055153067, 0.9999299, 1.0], + [-0.00014356381, 0.9999968, 1.0], + [-7.3728444e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.06313205, 0.9991679, 1.0], + [-0.0105344625, 0.99976623, 1.0], + [-0.00022694842, 0.99999404, 1.0], + [-6.2425016e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.07309622, 0.998452, 1.0], + [-0.010137941, 0.99966383, 1.0], + [-0.00015650428, 0.999994, 1.0], + [-2.0530616e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.12139271, 0.99886787, 1.0], + [-0.006217619, 0.9999299, 1.0], + [-2.767876e-05, 0.9999996, 1.0], + [-2.8060315e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11297469, 0.9986093, 1.0], + [-0.005666369, 0.99993217, 1.0], + [-2.3441991e-05, 0.99999976, 1.0], + [-1.6374731e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11892667, 0.9984859, 1.0], + [-0.0054892004, 0.9999281, 1.0], + [-1.8891958e-05, 0.9999997, 1.0], + [-1.42802365e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.063609995, 0.9991952, 1.0], + [-0.003612312, 0.9999425, 1.0], + [-5.6865858e-05, 0.99999845, 1.0], + [-2.8125933e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.03687637, 0.99945694, 1.0], + [-0.0022629017, 0.99996173, 1.0], + [-4.7338475e-05, 0.9999989, 1.0], + [-3.8873702e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.066277154, 0.9989287, 1.0], + [-0.0041808677, 0.9999447, 1.0], + [-2.6149986e-05, 0.9999997, 1.0], + [-7.8076916e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.18022063, 0.99584043, 1.0], + [-0.009360226, 0.99979895, 1.0], + [-4.573966e-05, 0.99999934, 1.0], + [-4.968432e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.057497833, 0.9985912, 1.0], + [-0.0030033966, 0.99992764, 1.0], + [-2.1283115e-05, 0.9999996, 1.0], + [-7.399287e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.02698668, 0.99940234, 1.0], + [-0.0018560152, 0.9999619, 1.0], + [-0.00011015628, 0.9999977, 1.0], + [-1.6312291e-05, 0.9999996, 1.0], + [0.0, 1.0, 1.0], + [-0.020353582, 0.99962854, 1.0], + [-0.0012829889, 0.99997663, 1.0], + [-4.823795e-05, 0.999999, 1.0], + [-7.425042e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.046696484, 0.9990762, 1.0], + [-0.0027994127, 0.999945, 1.0], + [-1.47203e-05, 0.9999997, 1.0], + [-1.4775334e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11455002, 0.9970088, 1.0], + [-0.008082145, 0.9997811, 1.0], + [-5.997455e-05, 0.9999983, 1.0], + [-2.153083e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.20616151, 0.9932033, 1.0], + [-0.026683627, 0.9989487, 1.0], + [-0.0006210741, 0.9999699, 1.0], + [-1.9223974e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.42821977, 0.98413795, 1.0], + [-0.09130513, 0.9955624, 1.0], + [-0.0039294222, 0.99976504, 1.0], + [-1.938189e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.737228, 0.9656451, 1.0], + [-0.11107813, 0.99402, 1.0], + [-0.005023841, 0.9997979, 1.0], + [-7.540838e-05, 0.9999977, 1.0], + [0.0, 1.0, 1.0], + [-2.0423653, 0.89530706, 1.0], + [-0.39499432, 0.9775829, 1.0], + [-0.021451676, 0.9990429, 1.0], + [-0.00062327104, 0.9999673, 1.0], + [0.0, 1.0, 1.0], + [-4.7882614, 0.7621558, 1.0], + [-1.4943185, 0.919121, 1.0], + [-0.26310703, 0.9902079, 1.0], + [-0.026021129, 0.9991246, 1.0], + [0.0, 1.0, 1.0], + [-5.967163, 0.70525557, 1.0], + [-6.2708335, 0.7825963, 1.0], + [-3.0123074, 0.9135249, 1.0], + [-0.16591884, 0.99503696, 1.0], + [0.0, 1.0, 1.0], + [-6.747682, 0.693125, 1.0], + [-11.015906, 0.6835952, 1.0], + [-10.588246, 0.7426725, 1.0], + [-1.4011235, 0.96407425, 1.0], + [0.0, 1.0, 1.0], + [-6.8046284, 0.6869158, 1.0], + [-12.226663, 0.67317563, 1.0], + [-14.401511, 0.68768495, 1.0], + [-5.656612, 0.8701306, 1.0], + [0.0, 1.0, 1.0], + [-5.2980156, 0.7554591, 1.0], + [-8.451796, 0.7457393, 1.0], + [-10.891963, 0.76741225, 1.0], + [-4.9737005, 0.8988167, 1.0], + [0.0, 1.0, 1.0], + [-3.7862368, 0.7663066, 1.0], + [-6.968383, 0.74598277, 1.0], + [-7.1698184, 0.82501894, 1.0], + [-3.5572019, 0.9294954, 1.0], + [0.0, 1.0, 1.0], + [-2.0710254, 0.80158305, 1.0], + [-2.644557, 0.82318443, 1.0], + [-2.829198, 0.9057661, 1.0], + [-3.1149986, 0.90927655, 1.0], + [0.0, 1.0, 1.0], + [0.5783251, 0.8887635, 1.0], + [0.14086528, 0.92088306, 1.0], + [-1.3454764, 0.91390127, 1.0], + [-2.1663988, 0.89477795, 1.0], + [0.0, 1.0, 1.0], + [0.90967906, 0.9097722, 1.0], + [0.40966874, 0.96211725, 1.0], + [0.012563121, 0.9758351, 1.0], + [-0.12836997, 0.9668173, 1.0], + [0.0, 1.0, 1.0], + [0.7635332, 0.9177408, 1.0], + [0.26205018, 0.9728322, 1.0], + [0.017903045, 0.99900806, 1.0], + [0.0027794365, 0.9998005, 1.0], + [0.0, 1.0, 1.0], + [0.70256805, 0.9528801, 1.0], + [0.12026947, 0.989671, 1.0], + [0.0014969448, 0.9997942, 1.0], + [4.7565347e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [0.43385658, 0.97707945, 1.0], + [0.08405786, 0.99513173, 1.0], + [0.001577933, 0.99989617, 1.0], + [2.1989558e-06, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [0.17016695, 0.990982, 1.0], + [0.020933557, 0.99893415, 1.0], + [0.00020579397, 0.99998814, 1.0], + [1.8093911e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.118497156, 0.9939799, 1.0], + [0.009560065, 0.99949676, 1.0], + [-6.1264276e-05, 0.99999744, 1.0], + [-7.5626076e-06, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.18632784, 0.9930626, 1.0], + [0.016471203, 0.9994041, 1.0], + [1.850781e-05, 0.99999624, 1.0], + [-1.3992527e-05, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.12490073, 0.99491405, 1.0], + [0.009683974, 0.9996138, 1.0], + [5.5344124e-05, 0.9999977, 1.0], + [-6.122279e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.046315443, 0.9974161, 1.0], + [0.0011095641, 0.99975616, 1.0], + [-9.514568e-05, 0.9999985, 1.0], + [-4.2969023e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.06989265, 0.99835086, 1.0], + [0.0046474864, 0.9999081, 1.0], + [8.086032e-06, 0.99999964, 1.0], + [-6.4283086e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.05445273, 0.9984405, 1.0], + [0.0056540556, 0.9999072, 1.0], + [3.1231557e-05, 0.99999934, 1.0], + [-1.4891012e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.049770087, 0.9989236, 1.0], + [0.0066248314, 0.9999419, 1.0], + [5.813844e-05, 0.99999964, 1.0], + [3.4580864e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.011926558, 0.9986787, 1.0], + [0.0030024045, 0.99992526, 1.0], + [5.3523254e-05, 0.9999996, 1.0], + [7.105971e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.0051469407, 0.99917525, 1.0], + [-0.0015877569, 0.99988997, 1.0], + [-0.0019601185, 0.99996835, 1.0], + [-0.00019134225, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [-0.0026346056, 0.9979655, 1.0], + [-0.6399264, 0.9890465, 1.0], + [-1.0591722, 0.9829664, 1.0], + [-0.1191338, 0.9980818, 1.0], + [0.0, 1.0, 1.0], + [-0.33427265, 0.9909866, 1.0], + [-6.1553082, 0.8892826, 1.0], + [-8.770668, 0.85096085, 1.0], + [-1.2606072, 0.9774506, 1.0], + [0.0, 1.0, 1.0], + [-0.47517955, 0.9840748, 1.0], + [-8.44668, 0.82929236, 1.0], + [-13.877107, 0.7396451, 1.0], + [-3.6484365, 0.9244635, 1.0], + [0.0, 1.0, 1.0], + [-0.47713858, 0.9821221, 1.0], + [-11.744733, 0.7621532, 1.0], + [-19.645943, 0.6432617, 1.0], + [-8.809313, 0.82319057, 1.0], + [0.0, 1.0, 1.0], + [-0.22001219, 0.98877585, 1.0], + [-9.559413, 0.79853284, 1.0], + [-19.126335, 0.6495684, 1.0], + [-10.234527, 0.7984638, 1.0], + [0.0, 1.0, 1.0], + [-0.15887243, 0.9931774, 1.0], + [-6.930633, 0.85245496, 1.0], + [-16.76443, 0.69196665, 1.0], + [-9.0214, 0.82428676, 1.0], + [0.0, 1.0, 1.0], + [-0.016224792, 0.9942058, 1.0], + [-0.8251402, 0.9754959, 1.0], + [-3.2789445, 0.9271614, 1.0], + [-1.1487821, 0.97528994, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-profiles/NIKON_Z_8.json b/assets/camera-profiles/NIKON_Z_8.json new file mode 100644 index 0000000000..0c63c0d841 --- /dev/null +++ b/assets/camera-profiles/NIKON_Z_8.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON Z 8", + "files": 261, + "samples": 969860, + "matrix": [ + [1.0990362785046823, -0.20725005493106963, 0.10268209021537977], + [-0.029117321941279695, 1.1458550830734442, -0.11855469779814065], + [-0.10582269960146817, -0.749496743582382, 1.900598651063195] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [7.111473, 1.181401, 1.0], + [2.9113195, 1.1633477, 1.0], + [1.8994527, 1.137069, 1.0], + [0.53534466, 1.0345078, 1.0], + [0.0, 1.0, 1.0], + [6.794022, 1.1620317, 1.0], + [1.6603756, 1.1454334, 1.0], + [0.65508085, 1.131233, 1.0], + [0.18744454, 1.0536848, 1.0], + [0.0, 1.0, 1.0], + [6.2740474, 1.1378076, 1.0], + [0.39785913, 1.126624, 1.0], + [-1.5408971, 1.0759563, 1.0], + [-1.7140917, 1.0247983, 1.0], + [0.0, 1.0, 1.0], + [5.2532353, 1.1126455, 1.0], + [-0.7449979, 1.1205007, 1.0], + [-3.0170422, 1.0559602, 1.0], + [-3.354577, 0.9959453, 1.0], + [0.0, 1.0, 1.0], + [4.8965535, 1.0955226, 1.0], + [-0.70895463, 1.1094975, 1.0], + [-3.1364403, 1.0476824, 1.0], + [-4.1989164, 0.96736634, 1.0], + [0.0, 1.0, 1.0], + [3.7045963, 1.0627788, 1.0], + [-1.3298069, 1.0956368, 1.0], + [-2.823391, 1.0708525, 1.0], + [-2.8166869, 1.0153031, 1.0], + [0.0, 1.0, 1.0], + [2.7337725, 1.037307, 1.0], + [-1.8260294, 1.0655074, 1.0], + [-3.5580583, 1.0488383, 1.0], + [-4.437229, 1.0113343, 1.0], + [0.0, 1.0, 1.0], + [2.3106112, 1.0036124, 1.0], + [-2.1256907, 1.0205824, 1.0], + [-4.961178, 1.0156583, 1.0], + [-6.710935, 0.9911362, 1.0], + [0.0, 1.0, 1.0], + [1.3536092, 0.9934402, 1.0], + [-2.5290065, 0.99898964, 1.0], + [-5.4174004, 0.9998236, 1.0], + [-6.597085, 0.9757822, 1.0], + [0.0, 1.0, 1.0], + [1.1366612, 1.002107, 1.0], + [-2.4894423, 0.9997597, 1.0], + [-5.4894304, 0.9861238, 1.0], + [-7.5021935, 0.92478454, 1.0], + [0.0, 1.0, 1.0], + [1.5145179, 1.021503, 1.0], + [-1.5998987, 1.0070803, 1.0], + [-4.508984, 0.9658881, 1.0], + [-7.158313, 0.89098585, 1.0], + [0.0, 1.0, 1.0], + [1.5639887, 1.0445297, 1.0], + [-0.915344, 1.0133737, 1.0], + [-3.1283567, 0.9622828, 1.0], + [-5.1986914, 0.8836733, 1.0], + [0.0, 1.0, 1.0], + [1.1355672, 1.0603209, 1.0], + [-0.56755894, 1.0115352, 1.0], + [-1.8851622, 0.95658684, 1.0], + [-3.4127562, 0.8781371, 1.0], + [0.0, 1.0, 1.0], + [0.5467292, 1.0580485, 1.0], + [-0.41302148, 1.0018384, 1.0], + [-1.0522548, 0.94771504, 1.0], + [-1.7539524, 0.876335, 1.0], + [0.0, 1.0, 1.0], + [-0.0725913, 1.0581536, 1.0], + [-0.4528266, 0.9990066, 1.0], + [-0.57961255, 0.9448505, 1.0], + [-0.5526036, 0.8833151, 1.0], + [0.0, 1.0, 1.0], + [-0.46772903, 1.0652338, 1.0], + [-0.02375252, 1.0044563, 1.0], + [0.43251258, 0.94911736, 1.0], + [0.87126744, 0.89478797, 1.0], + [0.0, 1.0, 1.0], + [0.16140896, 1.0854058, 1.0], + [1.07174, 1.0152693, 1.0], + [1.7586585, 0.95322454, 1.0], + [2.3524222, 0.8983894, 1.0], + [0.0, 1.0, 1.0], + [0.2469127, 1.1031706, 1.0], + [1.1097546, 1.0245277, 1.0], + [1.8333874, 0.96286947, 1.0], + [2.6193652, 0.9074495, 1.0], + [0.0, 1.0, 1.0], + [-0.6874714, 1.1182649, 1.0], + [0.16355518, 1.0515357, 1.0], + [1.1668121, 0.98603916, 1.0], + [2.3031707, 0.9206025, 1.0], + [0.0, 1.0, 1.0], + [-2.0503242, 1.1499335, 1.0], + [-1.1552397, 1.0879966, 1.0], + [-0.10152792, 1.0231872, 1.0], + [1.0718579, 0.95476115, 1.0], + [0.0, 1.0, 1.0], + [-3.8791447, 1.188533, 1.0], + [-2.9817088, 1.1327138, 1.0], + [-1.7755328, 1.0618997, 1.0], + [-0.21666291, 0.94862247, 1.0], + [0.0, 1.0, 1.0], + [-5.8841834, 1.226612, 1.0], + [-4.98382, 1.1740679, 1.0], + [-3.1025357, 1.0698453, 1.0], + [-0.9934275, 0.9242727, 1.0], + [0.0, 1.0, 1.0], + [-8.169295, 1.2657825, 1.0], + [-7.0660734, 1.2132481, 1.0], + [-3.1967385, 1.0559258, 1.0], + [-0.6672474, 0.97608167, 1.0], + [0.0, 1.0, 1.0], + [-10.515467, 1.3097212, 1.0], + [-8.952165, 1.2460706, 1.0], + [-2.6956723, 1.0394313, 1.0], + [-0.24548055, 0.9968061, 1.0], + [0.0, 1.0, 1.0], + [-13.161258, 1.348111, 1.0], + [-10.267992, 1.2503926, 1.0], + [-1.3459312, 1.0174122, 1.0], + [-0.040196028, 1.0000353, 1.0], + [0.0, 1.0, 1.0], + [-15.790122, 1.339094, 1.0], + [-11.393451, 1.2008783, 1.0], + [-2.688318, 1.0172836, 1.0], + [-0.20984244, 1.0009907, 1.0], + [0.0, 1.0, 1.0], + [-18.627531, 1.308051, 1.0], + [-12.985201, 1.160609, 1.0], + [-2.6413164, 1.014484, 1.0], + [-0.1162438, 1.0004718, 1.0], + [0.0, 1.0, 1.0], + [-19.562479, 1.2632611, 1.0], + [-12.712911, 1.108189, 1.0], + [-2.095739, 1.0048033, 1.0], + [-0.04567434, 0.99998564, 1.0], + [0.0, 1.0, 1.0], + [-20.291159, 1.2343935, 1.0], + [-12.380699, 1.0716544, 1.0], + [-1.7035112, 0.9987354, 1.0], + [-0.026139155, 0.9999211, 1.0], + [0.0, 1.0, 1.0], + [-19.989433, 1.200086, 1.0], + [-11.1118, 1.0480033, 1.0], + [-1.400926, 0.996738, 1.0], + [-0.024835525, 0.9998831, 1.0], + [0.0, 1.0, 1.0], + [-19.566994, 1.17641, 1.0], + [-10.257852, 1.0363005, 1.0], + [-1.2149851, 0.99804825, 1.0], + [-0.015627315, 0.9999487, 1.0], + [0.0, 1.0, 1.0], + [-18.913862, 1.134827, 1.0], + [-10.370755, 1.0167582, 1.0], + [-1.6505148, 0.99528724, 1.0], + [-0.026268613, 0.9998695, 1.0], + [0.0, 1.0, 1.0], + [-18.999414, 1.1098753, 1.0], + [-9.642386, 1.0115949, 1.0], + [-1.2675271, 0.9941188, 1.0], + [-0.018203475, 0.99986815, 1.0], + [0.0, 1.0, 1.0], + [-17.586956, 1.091192, 1.0], + [-8.722237, 1.0048285, 1.0], + [-1.1602849, 0.9960165, 1.0], + [-0.013571129, 0.9999434, 1.0], + [0.0, 1.0, 1.0], + [-16.729242, 1.0722094, 1.0], + [-9.583627, 0.9960505, 1.0], + [-2.039792, 0.9939141, 1.0], + [-0.04050307, 0.99985063, 1.0], + [0.0, 1.0, 1.0], + [-16.906395, 1.075484, 1.0], + [-8.840361, 1.0053103, 1.0], + [-1.4046353, 0.9954329, 1.0], + [-0.031679757, 0.99983394, 1.0], + [0.0, 1.0, 1.0], + [-17.531858, 1.0629116, 1.0], + [-8.608148, 0.9959869, 1.0], + [-1.1416123, 0.99636537, 1.0], + [-0.013378057, 0.99994534, 1.0], + [0.0, 1.0, 1.0], + [-17.99668, 1.0072999, 1.0], + [-10.570478, 0.9696376, 1.0], + [-1.9599729, 0.9910149, 1.0], + [-0.022619616, 0.99988747, 1.0], + [0.0, 1.0, 1.0], + [-18.86213, 0.96442, 1.0], + [-11.836861, 0.9444823, 1.0], + [-2.469638, 0.984263, 1.0], + [-0.030691203, 0.99978447, 1.0], + [0.0, 1.0, 1.0], + [-20.236654, 0.90864915, 1.0], + [-13.107022, 0.9121311, 1.0], + [-2.9203506, 0.97508854, 1.0], + [-0.041130785, 0.9996122, 1.0], + [0.0, 1.0, 1.0], + [-20.714214, 0.8536292, 1.0], + [-14.74853, 0.8750625, 1.0], + [-3.946734, 0.9621585, 1.0], + [-0.05774192, 0.9994195, 1.0], + [0.0, 1.0, 1.0], + [-20.098251, 0.80836505, 1.0], + [-14.476766, 0.851234, 1.0], + [-5.6719704, 0.9378101, 1.0], + [-0.12513989, 0.9985625, 1.0], + [0.0, 1.0, 1.0], + [-19.542671, 0.76297987, 1.0], + [-13.628136, 0.83560556, 1.0], + [-7.0074353, 0.91483426, 1.0], + [-0.28871387, 0.99629575, 1.0], + [0.0, 1.0, 1.0], + [-18.084225, 0.74823433, 1.0], + [-12.162158, 0.84476614, 1.0], + [-6.7999973, 0.9135009, 1.0], + [-0.40371415, 0.9944375, 1.0], + [0.0, 1.0, 1.0], + [-16.452961, 0.72636646, 1.0], + [-9.328942, 0.86091775, 1.0], + [-4.9608183, 0.9375208, 1.0], + [-0.27671492, 0.9964092, 1.0], + [0.0, 1.0, 1.0], + [-13.168701, 0.74453783, 1.0], + [-6.218363, 0.90167975, 1.0], + [-3.7400405, 0.95979875, 1.0], + [-0.46412376, 0.995171, 1.0], + [0.0, 1.0, 1.0], + [-10.4688425, 0.77134514, 1.0], + [-5.7574325, 0.8950614, 1.0], + [-3.034646, 0.9650316, 1.0], + [-0.36110362, 0.9969887, 1.0], + [0.0, 1.0, 1.0], + [-7.514723, 0.7996464, 1.0], + [-3.7689729, 0.9172442, 1.0], + [-0.9782931, 1.0084524, 1.0], + [0.31402144, 1.0211289, 1.0], + [0.0, 1.0, 1.0], + [-5.6537147, 0.8334752, 1.0], + [-2.504266, 0.96418613, 1.0], + [-0.07010336, 1.052774, 1.0], + [0.894411, 1.0569643, 1.0], + [0.0, 1.0, 1.0], + [-4.493737, 0.7882688, 1.0], + [-2.3410633, 0.936153, 1.0], + [-0.32673165, 1.0513396, 1.0], + [0.49912313, 1.0484643, 1.0], + [0.0, 1.0, 1.0], + [-1.7034019, 0.73160046, 1.0], + [-0.831635, 0.8589306, 1.0], + [-0.3882015, 1.0134665, 1.0], + [-0.18704136, 1.0146226, 1.0], + [0.0, 1.0, 1.0], + [0.61953825, 0.7258855, 1.0], + [0.81030744, 0.8431724, 1.0], + [0.00040164185, 1.0047435, 1.0], + [-0.10240378, 1.0050768, 1.0], + [0.0, 1.0, 1.0], + [2.0624804, 0.7425685, 1.0], + [1.5886667, 0.8645959, 1.0], + [0.4176404, 1.0037674, 1.0], + [-0.00398716, 1.0012411, 1.0], + [0.0, 1.0, 1.0], + [3.623457, 0.7510747, 1.0], + [1.7844758, 0.885587, 1.0], + [-0.036421247, 1.0089039, 1.0], + [-0.020441405, 1.001009, 1.0], + [0.0, 1.0, 1.0], + [4.7174945, 0.7708686, 1.0], + [3.2143273, 0.90923154, 1.0], + [0.25317824, 1.0065747, 1.0], + [-0.016773207, 1.0006565, 1.0], + [0.0, 1.0, 1.0], + [5.9824314, 0.7961609, 1.0], + [3.939417, 0.94614726, 1.0], + [0.7240807, 1.0062363, 1.0], + [0.030703958, 1.0001671, 1.0], + [0.0, 1.0, 1.0], + [7.3545732, 0.82526684, 1.0], + [4.982609, 0.9743153, 1.0], + [1.4535582, 1.0078721, 1.0], + [0.062866114, 1.0002259, 1.0], + [0.0, 1.0, 1.0], + [7.4568152, 0.8617716, 1.0], + [3.6539178, 1.0011972, 1.0], + [0.9039327, 1.0089482, 1.0], + [0.03915892, 1.000176, 1.0], + [0.0, 1.0, 1.0], + [7.3634186, 0.91159344, 1.0], + [3.4343126, 1.0100657, 1.0], + [0.695478, 1.010742, 1.0], + [0.03255169, 1.0004065, 1.0], + [0.0, 1.0, 1.0], + [7.238165, 0.9486031, 1.0], + [4.089919, 1.0266953, 1.0], + [1.1045972, 1.0186851, 1.0], + [0.057033073, 1.0010694, 1.0], + [0.0, 1.0, 1.0], + [7.0543814, 0.9770822, 1.0], + [3.9128, 1.0336262, 1.0], + [1.1944752, 1.0113511, 1.0], + [0.07083795, 1.0004443, 1.0], + [0.0, 1.0, 1.0], + [8.143442, 0.99413896, 1.0], + [4.040843, 1.0496417, 1.0], + [0.8080747, 1.0116086, 1.0], + [0.042427447, 0.99993575, 1.0], + [0.0, 1.0, 1.0], + [7.18577, 0.9783746, 1.0], + [3.2767346, 1.0421677, 1.0], + [1.2492384, 1.016086, 1.0], + [0.15854485, 1.0005085, 1.0], + [0.0, 1.0, 1.0], + [7.194208, 1.0016203, 1.0], + [3.0112083, 1.0349026, 1.0], + [0.91562295, 1.0119883, 1.0], + [0.0654759, 1.0005552, 1.0], + [0.0, 1.0, 1.0], + [7.414966, 0.998843, 1.0], + [3.1521475, 1.0262057, 1.0], + [0.9972541, 1.0102346, 1.0], + [0.060677312, 1.0006106, 1.0], + [0.0, 1.0, 1.0], + [9.276159, 0.9916557, 1.0], + [4.375442, 1.0329365, 1.0], + [1.5409418, 1.015073, 1.0], + [0.13645935, 1.0012853, 1.0], + [0.0, 1.0, 1.0], + [9.8098955, 1.0221618, 1.0], + [5.381683, 1.0610034, 1.0], + [2.1655529, 1.0283315, 1.0], + [0.15032019, 1.0015818, 1.0], + [0.0, 1.0, 1.0], + [8.995072, 1.0311706, 1.0], + [5.2788897, 1.0783035, 1.0], + [2.6601613, 1.0430328, 1.0], + [0.23225841, 1.0026511, 1.0], + [0.0, 1.0, 1.0], + [10.116063, 1.0506214, 1.0], + [6.2658486, 1.0895245, 1.0], + [3.640211, 1.0535048, 1.0], + [0.39292678, 1.0040704, 1.0], + [0.0, 1.0, 1.0], + [9.800368, 1.0582323, 1.0], + [6.5737424, 1.0822098, 1.0], + [6.3034444, 1.0650462, 1.0], + [1.6771196, 1.0111806, 1.0], + [0.0, 1.0, 1.0], + [9.205544, 1.097704, 1.0], + [5.412633, 1.1020687, 1.0], + [4.3975163, 1.0531521, 1.0], + [1.2703937, 1.0038551, 1.0], + [0.0, 1.0, 1.0], + [8.4540615, 1.1362739, 1.0], + [4.478308, 1.1312087, 1.0], + [2.8681872, 1.0796018, 1.0], + [0.52417415, 1.009483, 1.0], + [0.0, 1.0, 1.0], + [6.872955, 1.1356013, 1.0], + [2.2753267, 1.1355586, 1.0], + [1.7234284, 1.1281133, 1.0], + [1.1223476, 1.0683283, 1.0], + [0.0, 1.0, 1.0], + [6.124278, 1.1170305, 1.0], + [0.4689261, 1.1054591, 1.0], + [-0.22648379, 1.088597, 1.0], + [-0.41844296, 1.0501652, 1.0], + [0.0, 1.0, 1.0], + [5.5311556, 1.0830601, 1.0], + [-0.7712272, 1.0794301, 1.0], + [-2.3635144, 1.0375766, 1.0], + [-2.6961873, 1.0000627, 1.0], + [0.0, 1.0, 1.0], + [4.776837, 1.051424, 1.0], + [-1.30482, 1.0655525, 1.0], + [-3.4497104, 1.0115541, 1.0], + [-4.008624, 0.9658105, 1.0], + [0.0, 1.0, 1.0], + [4.325843, 1.0289863, 1.0], + [-0.89701647, 1.0507549, 1.0], + [-3.5267189, 0.9840365, 1.0], + [-4.8896995, 0.92570627, 1.0], + [0.0, 1.0, 1.0], + [3.2383246, 0.9979077, 1.0], + [-1.0782118, 1.0382204, 1.0], + [-2.7714362, 1.0106578, 1.0], + [-3.8654947, 0.9470026, 1.0], + [0.0, 1.0, 1.0], + [2.3593056, 0.97518253, 1.0], + [-1.4679712, 1.0127448, 1.0], + [-3.3288295, 1.0004958, 1.0], + [-4.8622665, 0.95781606, 1.0], + [0.0, 1.0, 1.0], + [1.5416691, 0.94487643, 1.0], + [-1.9971544, 0.97270143, 1.0], + [-4.8065176, 0.9733487, 1.0], + [-6.8176403, 0.9401679, 1.0], + [0.0, 1.0, 1.0], + [0.57058907, 0.9429605, 1.0], + [-2.4935536, 0.96301734, 1.0], + [-5.3679204, 0.9637666, 1.0], + [-6.831026, 0.91412973, 1.0], + [0.0, 1.0, 1.0], + [0.26053378, 0.9610666, 1.0], + [-2.7039409, 0.9701933, 1.0], + [-5.4002733, 0.9458906, 1.0], + [-6.9784875, 0.86947393, 1.0], + [0.0, 1.0, 1.0], + [0.53928864, 0.9811304, 1.0], + [-2.0121186, 0.97265023, 1.0], + [-4.3233175, 0.9172091, 1.0], + [-6.005866, 0.8446555, 1.0], + [0.0, 1.0, 1.0], + [0.6283378, 1.0006269, 1.0], + [-1.4216532, 0.9777993, 1.0], + [-3.27393, 0.91542476, 1.0], + [-4.512793, 0.8313424, 1.0], + [0.0, 1.0, 1.0], + [0.36547175, 1.0127599, 1.0], + [-1.0703696, 0.9762654, 1.0], + [-2.3095455, 0.9178097, 1.0], + [-3.2788665, 0.82293683, 1.0], + [0.0, 1.0, 1.0], + [-0.073588796, 1.014126, 1.0], + [-0.83646154, 0.96881944, 1.0], + [-1.3723681, 0.9147329, 1.0], + [-1.7665399, 0.82023007, 1.0], + [0.0, 1.0, 1.0], + [-0.63139224, 1.017959, 1.0], + [-0.81763905, 0.96828437, 1.0], + [-0.8057263, 0.91791266, 1.0], + [-0.65346074, 0.85073775, 1.0], + [0.0, 1.0, 1.0], + [-0.9540565, 1.0314627, 1.0], + [-0.4009139, 0.97503597, 1.0], + [0.16406323, 0.91989255, 1.0], + [0.69900894, 0.8609631, 1.0], + [0.0, 1.0, 1.0], + [-0.29338956, 1.051337, 1.0], + [0.6638694, 0.9824637, 1.0], + [1.4182094, 0.92527664, 1.0], + [2.0502326, 0.8743307, 1.0], + [0.0, 1.0, 1.0], + [-0.19034386, 1.060521, 1.0], + [0.7418265, 0.9920945, 1.0], + [1.5311259, 0.94175714, 1.0], + [2.3645382, 0.89062786, 1.0], + [0.0, 1.0, 1.0], + [-1.1072253, 1.0738078, 1.0], + [-0.17039186, 1.0250928, 1.0], + [1.0041893, 0.9713254, 1.0], + [2.2262986, 0.9069038, 1.0], + [0.0, 1.0, 1.0], + [-2.3444607, 1.0938511, 1.0], + [-1.396141, 1.0545899, 1.0], + [-0.19473651, 1.0042597, 1.0], + [1.0580148, 0.93644226, 1.0], + [0.0, 1.0, 1.0], + [-3.9988203, 1.1291953, 1.0], + [-3.121686, 1.0951587, 1.0], + [-1.7795982, 1.0358233, 1.0], + [-0.33727086, 0.92730236, 1.0], + [0.0, 1.0, 1.0], + [-5.7153583, 1.1640722, 1.0], + [-4.88902, 1.1327654, 1.0], + [-3.3953416, 1.0511376, 1.0], + [-1.5109448, 0.885162, 1.0], + [0.0, 1.0, 1.0], + [-7.723858, 1.1939173, 1.0], + [-6.834126, 1.162886, 1.0], + [-4.4360557, 1.0393157, 1.0], + [-2.0289664, 0.9277147, 1.0], + [0.0, 1.0, 1.0], + [-9.929747, 1.233579, 1.0], + [-8.989848, 1.1980727, 1.0], + [-5.8884463, 1.0467408, 1.0], + [-1.4371129, 0.9823743, 1.0], + [0.0, 1.0, 1.0], + [-12.79711, 1.2787904, 1.0], + [-11.43725, 1.2281201, 1.0], + [-5.0934134, 1.0372151, 1.0], + [-0.3870912, 0.99834675, 1.0], + [0.0, 1.0, 1.0], + [-15.6093645, 1.2775221, 1.0], + [-13.393321, 1.1939902, 1.0], + [-7.004254, 1.02318, 1.0], + [-0.8632698, 1.0002364, 1.0], + [0.0, 1.0, 1.0], + [-18.6125, 1.2465408, 1.0], + [-15.71152, 1.1482728, 1.0], + [-7.1894984, 1.0131606, 1.0], + [-0.6019608, 0.9991237, 1.0], + [0.0, 1.0, 1.0], + [-19.882221, 1.2043245, 1.0], + [-16.663143, 1.0912375, 1.0], + [-6.9870176, 0.9956438, 1.0], + [-0.2885382, 0.9992374, 1.0], + [0.0, 1.0, 1.0], + [-20.625488, 1.1759886, 1.0], + [-16.814833, 1.0537983, 1.0], + [-6.23361, 0.9808524, 1.0], + [-0.16323197, 0.99922365, 1.0], + [0.0, 1.0, 1.0], + [-21.191574, 1.1593459, 1.0], + [-16.181896, 1.0420355, 1.0], + [-4.434142, 0.979971, 1.0], + [-0.098916985, 0.9993104, 1.0], + [0.0, 1.0, 1.0], + [-21.118694, 1.1469342, 1.0], + [-15.308236, 1.0306582, 1.0], + [-3.593454, 0.9800867, 1.0], + [-0.11090971, 0.99904144, 1.0], + [0.0, 1.0, 1.0], + [-20.06453, 1.1023701, 1.0], + [-16.392384, 0.9912434, 1.0], + [-6.5470276, 0.95821047, 1.0], + [-0.29561612, 0.99735236, 1.0], + [0.0, 1.0, 1.0], + [-20.86482, 1.0911682, 1.0], + [-16.905964, 0.9835901, 1.0], + [-6.5230694, 0.95751834, 1.0], + [-0.18875386, 0.99822915, 1.0], + [0.0, 1.0, 1.0], + [-19.563591, 1.0770866, 1.0], + [-15.724344, 0.9894494, 1.0], + [-5.0314937, 0.9782653, 1.0], + [-0.08916562, 0.99951047, 1.0], + [0.0, 1.0, 1.0], + [-19.648596, 1.066223, 1.0], + [-15.772977, 0.97731113, 1.0], + [-6.1778703, 0.97096556, 1.0], + [-0.17319372, 0.999023, 1.0], + [0.0, 1.0, 1.0], + [-19.83075, 1.0626674, 1.0], + [-15.684646, 0.9758881, 1.0], + [-5.8669987, 0.96583444, 1.0], + [-0.21658812, 0.99854726, 1.0], + [0.0, 1.0, 1.0], + [-19.55086, 1.04659, 1.0], + [-14.666198, 0.9744223, 1.0], + [-4.026313, 0.98011285, 1.0], + [-0.07394151, 0.9995447, 1.0], + [0.0, 1.0, 1.0], + [-20.196869, 0.988159, 1.0], + [-17.390198, 0.93337435, 1.0], + [-7.4060826, 0.95599365, 1.0], + [-0.14739579, 0.99894, 1.0], + [0.0, 1.0, 1.0], + [-21.26852, 0.9264197, 1.0], + [-18.344461, 0.8974489, 1.0], + [-8.743559, 0.936538, 1.0], + [-0.19807786, 0.99823266, 1.0], + [0.0, 1.0, 1.0], + [-21.510828, 0.87103164, 1.0], + [-18.518517, 0.86176705, 1.0], + [-10.065859, 0.9085589, 1.0], + [-0.2821744, 0.99699414, 1.0], + [0.0, 1.0, 1.0], + [-21.584885, 0.8148765, 1.0], + [-18.512503, 0.82746816, 1.0], + [-11.73013, 0.8824068, 1.0], + [-0.3848725, 0.9958189, 1.0], + [0.0, 1.0, 1.0], + [-20.356161, 0.77016896, 1.0], + [-16.736427, 0.8110233, 1.0], + [-13.46877, 0.8463329, 1.0], + [-1.0992389, 0.9864028, 1.0], + [0.0, 1.0, 1.0], + [-19.00107, 0.7378436, 1.0], + [-14.466894, 0.8157218, 1.0], + [-12.32627, 0.84496945, 1.0], + [-2.4299142, 0.9672269, 1.0], + [0.0, 1.0, 1.0], + [-17.491095, 0.72382104, 1.0], + [-11.5228195, 0.84021336, 1.0], + [-9.1774845, 0.87830716, 1.0], + [-2.8254447, 0.9605242, 1.0], + [0.0, 1.0, 1.0], + [-15.435101, 0.7124373, 1.0], + [-8.480166, 0.8606328, 1.0], + [-5.882448, 0.91346925, 1.0], + [-1.6596885, 0.9736952, 1.0], + [0.0, 1.0, 1.0], + [-9.965368, 0.78840566, 1.0], + [-5.0230317, 0.92467666, 1.0], + [-3.841451, 0.95225155, 1.0], + [-2.0082777, 0.97166634, 1.0], + [0.0, 1.0, 1.0], + [-7.585785, 0.8164634, 1.0], + [-4.2593484, 0.9317818, 1.0], + [-2.753388, 0.96636844, 1.0], + [-0.8575842, 0.98519945, 1.0], + [0.0, 1.0, 1.0], + [-5.9080076, 0.82606196, 1.0], + [-2.4876971, 0.95708805, 1.0], + [-0.017117301, 1.0234948, 1.0], + [1.1949928, 1.0367807, 1.0], + [0.0, 1.0, 1.0], + [-4.3217926, 0.8621694, 1.0], + [-1.1579821, 1.013248, 1.0], + [0.829161, 1.0717134, 1.0], + [1.6019802, 1.078209, 1.0], + [0.0, 1.0, 1.0], + [-3.4365811, 0.801541, 1.0], + [-1.142285, 0.9963089, 1.0], + [0.6441237, 1.0807948, 1.0], + [1.3309326, 1.0855218, 1.0], + [0.0, 1.0, 1.0], + [-1.0976561, 0.7120078, 1.0], + [-0.8466615, 0.89612305, 1.0], + [-0.7710759, 1.0564098, 1.0], + [-0.6550111, 1.07382, 1.0], + [0.0, 1.0, 1.0], + [0.8495644, 0.71387726, 1.0], + [0.49764365, 0.8776602, 1.0], + [-0.9067246, 1.0398972, 1.0], + [-1.0517284, 1.0432695, 1.0], + [0.0, 1.0, 1.0], + [2.4473016, 0.72682, 1.0], + [1.7983775, 0.88795257, 1.0], + [0.20548964, 1.0286621, 1.0], + [-0.35707185, 1.0189359, 1.0], + [0.0, 1.0, 1.0], + [4.0796447, 0.7306628, 1.0], + [2.1504579, 0.89834225, 1.0], + [0.34639126, 1.0416607, 1.0], + [-0.23788506, 1.019259, 1.0], + [0.0, 1.0, 1.0], + [5.6701546, 0.74359995, 1.0], + [4.0726633, 0.91694146, 1.0], + [1.3216503, 1.0316267, 1.0], + [-0.045850165, 1.0076267, 1.0], + [0.0, 1.0, 1.0], + [7.0043573, 0.7694095, 1.0], + [5.4557076, 0.9445862, 1.0], + [4.0584545, 1.0185257, 1.0], + [0.56355107, 1.0008852, 1.0], + [0.0, 1.0, 1.0], + [8.084995, 0.78970784, 1.0], + [5.8458114, 0.970168, 1.0], + [4.5538306, 1.024938, 1.0], + [0.49345702, 1.0022416, 1.0], + [0.0, 1.0, 1.0], + [8.05806, 0.817034, 1.0], + [4.4210987, 1.0048858, 1.0], + [3.0152867, 1.0326685, 1.0], + [0.38135135, 1.0021529, 1.0], + [0.0, 1.0, 1.0], + [9.005357, 0.85885465, 1.0], + [4.763144, 1.0251466, 1.0], + [2.17603, 1.0425395, 1.0], + [0.36619714, 1.0030634, 1.0], + [0.0, 1.0, 1.0], + [8.993758, 0.90082896, 1.0], + [5.2438936, 1.0453547, 1.0], + [2.549889, 1.0553246, 1.0], + [0.32793412, 1.0068744, 1.0], + [0.0, 1.0, 1.0], + [9.829749, 0.9506837, 1.0], + [6.0920825, 1.0672028, 1.0], + [3.7592368, 1.0513718, 1.0], + [0.4075813, 1.0041977, 1.0], + [0.0, 1.0, 1.0], + [9.575895, 0.9809986, 1.0], + [5.147536, 1.0732907, 1.0], + [2.6697485, 1.0479143, 1.0], + [0.23983598, 1.0018369, 1.0], + [0.0, 1.0, 1.0], + [8.565204, 0.9654144, 1.0], + [4.159953, 1.0592482, 1.0], + [2.982862, 1.046513, 1.0], + [0.51444083, 1.0031579, 1.0], + [0.0, 1.0, 1.0], + [9.271386, 0.9671414, 1.0], + [3.9021678, 1.0480341, 1.0], + [2.3847647, 1.0395492, 1.0], + [0.338095, 1.0032356, 1.0], + [0.0, 1.0, 1.0], + [9.753154, 0.9583014, 1.0], + [4.58982, 1.0358695, 1.0], + [3.3719125, 1.0326496, 1.0], + [0.62065965, 1.0025914, 1.0], + [0.0, 1.0, 1.0], + [10.777158, 0.9585278, 1.0], + [5.382428, 1.024311, 1.0], + [3.983915, 1.028954, 1.0], + [0.95064604, 1.0067729, 1.0], + [0.0, 1.0, 1.0], + [10.107686, 0.98531634, 1.0], + [5.2336845, 1.0421758, 1.0], + [3.7427158, 1.0352445, 1.0], + [0.91338927, 1.0056044, 1.0], + [0.0, 1.0, 1.0], + [10.010471, 1.002975, 1.0], + [5.0806794, 1.0562643, 1.0], + [3.9300086, 1.0432692, 1.0], + [1.2768706, 1.0071726, 1.0], + [0.0, 1.0, 1.0], + [10.721367, 1.020363, 1.0], + [5.9179363, 1.0760281, 1.0], + [4.653525, 1.0701501, 1.0], + [1.5535151, 1.0154992, 1.0], + [0.0, 1.0, 1.0], + [10.558615, 1.0318329, 1.0], + [6.080362, 1.067487, 1.0], + [6.671132, 1.0704038, 1.0], + [4.238108, 1.0257412, 1.0], + [0.0, 1.0, 1.0], + [9.48343, 1.0585111, 1.0], + [4.4426146, 1.0725648, 1.0], + [4.5216165, 1.0536869, 1.0], + [3.2611907, 1.0090235, 1.0], + [0.0, 1.0, 1.0], + [8.163124, 1.0967343, 1.0], + [3.4163864, 1.1021494, 1.0], + [3.0036535, 1.0894843, 1.0], + [1.6298203, 1.0331198, 1.0], + [0.0, 1.0, 1.0], + [4.7200303, 0.99990386, 1.0], + [-0.18434145, 0.9829197, 1.0], + [-1.7782454, 0.9463851, 1.0], + [-0.8780288, 0.9674484, 1.0], + [0.0, 1.0, 1.0], + [3.3225217, 0.9776916, 1.0], + [-1.491963, 0.9430486, 1.0], + [-2.2781556, 0.91853374, 1.0], + [-1.9229406, 0.92325467, 1.0], + [0.0, 1.0, 1.0], + [2.4281845, 0.93801886, 1.0], + [-2.0302162, 0.92144394, 1.0], + [-2.984083, 0.90740937, 1.0], + [-2.8202295, 0.9010818, 1.0], + [0.0, 1.0, 1.0], + [2.21699, 0.9216663, 1.0], + [-1.478845, 0.9148489, 1.0], + [-3.1692345, 0.9026742, 1.0], + [-3.4822135, 0.8847937, 1.0], + [0.0, 1.0, 1.0], + [2.6095815, 0.91278374, 1.0], + [-0.50396746, 0.90550876, 1.0], + [-2.5619872, 0.8661648, 1.0], + [-3.3487625, 0.8425053, 1.0], + [0.0, 1.0, 1.0], + [2.2401028, 0.9033001, 1.0], + [-0.25377062, 0.8960398, 1.0], + [-2.032842, 0.87459487, 1.0], + [-2.7365766, 0.83498585, 1.0], + [0.0, 1.0, 1.0], + [1.695987, 0.8888191, 1.0], + [-0.67016447, 0.88870287, 1.0], + [-2.36245, 0.87585235, 1.0], + [-3.2576814, 0.8303152, 1.0], + [0.0, 1.0, 1.0], + [0.9194321, 0.8731023, 1.0], + [-1.3481888, 0.8760105, 1.0], + [-3.32636, 0.8514322, 1.0], + [-4.274623, 0.8039877, 1.0], + [0.0, 1.0, 1.0], + [0.23748975, 0.8785199, 1.0], + [-1.6813989, 0.8869438, 1.0], + [-4.1970234, 0.867815, 1.0], + [-4.993503, 0.8058187, 1.0], + [0.0, 1.0, 1.0], + [-0.0443294, 0.89978844, 1.0], + [-1.9890785, 0.90232074, 1.0], + [-3.950284, 0.85917366, 1.0], + [-4.1894603, 0.7918877, 1.0], + [0.0, 1.0, 1.0], + [0.29374087, 0.92003465, 1.0], + [-1.5607008, 0.90463936, 1.0], + [-2.7787287, 0.8298095, 1.0], + [-2.9107876, 0.7702355, 1.0], + [0.0, 1.0, 1.0], + [0.2690241, 0.9420806, 1.0], + [-1.3042494, 0.91745335, 1.0], + [-2.3432212, 0.8269896, 1.0], + [-2.5380266, 0.7619623, 1.0], + [0.0, 1.0, 1.0], + [0.00078135007, 0.9576381, 1.0], + [-1.0908285, 0.9226896, 1.0], + [-1.7685524, 0.83202, 1.0], + [-1.8833431, 0.7369599, 1.0], + [0.0, 1.0, 1.0], + [-0.3298981, 0.9611947, 1.0], + [-0.9030142, 0.9218993, 1.0], + [-1.1803176, 0.84522974, 1.0], + [-1.2364491, 0.7347839, 1.0], + [0.0, 1.0, 1.0], + [-0.90065134, 0.96639967, 1.0], + [-0.92589504, 0.9220938, 1.0], + [-0.7533085, 0.86246485, 1.0], + [-0.58836806, 0.7862185, 1.0], + [0.0, 1.0, 1.0], + [-1.2350203, 0.982918, 1.0], + [-0.63451254, 0.92791486, 1.0], + [-0.05854761, 0.8623331, 1.0], + [0.30643168, 0.8013406, 1.0], + [0.0, 1.0, 1.0], + [-0.6747929, 0.99744356, 1.0], + [0.28179812, 0.93459874, 1.0], + [0.98867965, 0.88537693, 1.0], + [1.5234042, 0.83812547, 1.0], + [0.0, 1.0, 1.0], + [-0.5743862, 1.0066322, 1.0], + [0.4175759, 0.9530498, 1.0], + [1.1614387, 0.9119914, 1.0], + [1.933506, 0.86427605, 1.0], + [0.0, 1.0, 1.0], + [-1.3801366, 1.0321106, 1.0], + [-0.38676104, 0.9921655, 1.0], + [0.70755553, 0.94378203, 1.0], + [1.802139, 0.8818721, 1.0], + [0.0, 1.0, 1.0], + [-2.5278633, 1.0482858, 1.0], + [-1.6080537, 1.0186095, 1.0], + [-0.4270131, 0.96137357, 1.0], + [0.52042204, 0.89067566, 1.0], + [0.0, 1.0, 1.0], + [-3.9787476, 1.078391, 1.0], + [-3.1643727, 1.050093, 1.0], + [-1.7115612, 0.9687348, 1.0], + [-0.4047129, 0.8798787, 1.0], + [0.0, 1.0, 1.0], + [-5.596516, 1.1130706, 1.0], + [-4.6923046, 1.0734227, 1.0], + [-2.8240192, 0.92865837, 1.0], + [-1.3897139, 0.8587299, 1.0], + [0.0, 1.0, 1.0], + [-7.4136, 1.1421592, 1.0], + [-6.2911453, 1.0910819, 1.0], + [-3.5203412, 0.91654646, 1.0], + [-1.5387671, 0.8988556, 1.0], + [0.0, 1.0, 1.0], + [-9.345446, 1.175205, 1.0], + [-7.8788543, 1.1139026, 1.0], + [-3.9887538, 0.95732474, 1.0], + [-0.7494897, 0.9770353, 1.0], + [0.0, 1.0, 1.0], + [-11.726303, 1.2018178, 1.0], + [-9.235658, 1.1204212, 1.0], + [-2.6193218, 0.99971265, 1.0], + [-0.17413116, 0.9981184, 1.0], + [0.0, 1.0, 1.0], + [-13.852155, 1.1776309, 1.0], + [-9.851412, 1.0741005, 1.0], + [-3.3592322, 0.99141484, 1.0], + [-0.30808955, 0.99881417, 1.0], + [0.0, 1.0, 1.0], + [-15.295475, 1.1342069, 1.0], + [-10.099276, 1.0167769, 1.0], + [-3.3911893, 0.9816512, 1.0], + [-0.22844037, 0.99877155, 1.0], + [0.0, 1.0, 1.0], + [-15.857363, 1.0911952, 1.0], + [-10.737116, 0.98477983, 1.0], + [-3.0231202, 0.9772009, 1.0], + [-0.08856626, 0.99933064, 1.0], + [0.0, 1.0, 1.0], + [-16.511763, 1.0658559, 1.0], + [-10.546561, 0.97217023, 1.0], + [-2.431429, 0.97843724, 1.0], + [-0.04837607, 0.9995322, 1.0], + [0.0, 1.0, 1.0], + [-17.32334, 1.046019, 1.0], + [-9.642773, 0.9659112, 1.0], + [-1.8824904, 0.9784988, 1.0], + [-0.039536994, 0.9995053, 1.0], + [0.0, 1.0, 1.0], + [-16.230003, 1.0588695, 1.0], + [-7.7154303, 0.98527706, 1.0], + [-1.1871514, 0.9866838, 1.0], + [-0.05562747, 0.99940675, 1.0], + [0.0, 1.0, 1.0], + [-14.798325, 1.0390636, 1.0], + [-9.137856, 0.95683104, 1.0], + [-3.1458273, 0.96849316, 1.0], + [-0.17147331, 0.99812067, 1.0], + [0.0, 1.0, 1.0], + [-15.813182, 1.0053109, 1.0], + [-10.048948, 0.94771963, 1.0], + [-3.4650283, 0.9650353, 1.0], + [-0.12416336, 0.9983432, 1.0], + [0.0, 1.0, 1.0], + [-16.047022, 0.98889244, 1.0], + [-8.209296, 0.96882516, 1.0], + [-2.0700836, 0.98336583, 1.0], + [-0.05653161, 0.9994855, 1.0], + [0.0, 1.0, 1.0], + [-16.9603, 0.97119784, 1.0], + [-8.66278, 0.9401887, 1.0], + [-2.2208483, 0.9735744, 1.0], + [-0.05832822, 0.9992916, 1.0], + [0.0, 1.0, 1.0], + [-15.93582, 0.9818394, 1.0], + [-9.735115, 0.91714615, 1.0], + [-2.8493056, 0.9654458, 1.0], + [-0.06305568, 0.99927205, 1.0], + [0.0, 1.0, 1.0], + [-14.386252, 0.98472387, 1.0], + [-8.998497, 0.9461114, 1.0], + [-2.1540806, 0.9810613, 1.0], + [-0.04269774, 0.99961257, 1.0], + [0.0, 1.0, 1.0], + [-15.852745, 0.9424422, 1.0], + [-13.541525, 0.9004132, 1.0], + [-5.0671234, 0.95313597, 1.0], + [-0.12552416, 0.99874544, 1.0], + [0.0, 1.0, 1.0], + [-17.605848, 0.8931836, 1.0], + [-11.864264, 0.9022595, 1.0], + [-3.5728223, 0.95726645, 1.0], + [-0.09980284, 0.9986085, 1.0], + [0.0, 1.0, 1.0], + [-18.33637, 0.8163921, 1.0], + [-12.252236, 0.8543749, 1.0], + [-3.4134076, 0.94611895, 1.0], + [-0.07950901, 0.9985124, 1.0], + [0.0, 1.0, 1.0], + [-19.060442, 0.7734047, 1.0], + [-15.23906, 0.8017895, 1.0], + [-5.954306, 0.91588944, 1.0], + [-0.12067752, 0.9982148, 1.0], + [0.0, 1.0, 1.0], + [-19.100754, 0.72602576, 1.0], + [-15.591848, 0.7819147, 1.0], + [-9.629234, 0.86622393, 1.0], + [-0.3522098, 0.99482995, 1.0], + [0.0, 1.0, 1.0], + [-18.568441, 0.6896306, 1.0], + [-14.766998, 0.7783395, 1.0], + [-11.441, 0.83353055, 1.0], + [-0.95255774, 0.98503613, 1.0], + [0.0, 1.0, 1.0], + [-17.873262, 0.66292804, 1.0], + [-11.700582, 0.7895474, 1.0], + [-7.4423504, 0.87738365, 1.0], + [-0.7649209, 0.9871464, 1.0], + [0.0, 1.0, 1.0], + [-15.81971, 0.65139574, 1.0], + [-9.3584585, 0.78892857, 1.0], + [-5.956205, 0.8730324, 1.0], + [-0.8720731, 0.9798331, 1.0], + [0.0, 1.0, 1.0], + [-9.644368, 0.7343603, 1.0], + [-5.3256245, 0.8688635, 1.0], + [-4.0904546, 0.905134, 1.0], + [-1.9802942, 0.942991, 1.0], + [0.0, 1.0, 1.0], + [-6.834648, 0.768598, 1.0], + [-3.7342772, 0.8917114, 1.0], + [-2.275454, 0.9256257, 1.0], + [-0.91388834, 0.94262, 1.0], + [0.0, 1.0, 1.0], + [-4.958538, 0.79897887, 1.0], + [-1.7752197, 0.92934436, 1.0], + [-0.052028436, 0.9775266, 1.0], + [1.0069743, 0.99931246, 1.0], + [0.0, 1.0, 1.0], + [-3.5435648, 0.83892524, 1.0], + [-0.45535767, 0.99585503, 1.0], + [1.1220992, 1.0467564, 1.0], + [1.688386, 1.0540297, 1.0], + [0.0, 1.0, 1.0], + [-2.584903, 0.7611296, 1.0], + [-0.29603556, 0.99536204, 1.0], + [1.0826626, 1.0670043, 1.0], + [1.5285076, 1.0699658, 1.0], + [0.0, 1.0, 1.0], + [-0.24621776, 0.6534611, 1.0], + [-0.5641967, 0.88916516, 1.0], + [-0.5208639, 1.0147351, 1.0], + [-0.17287369, 1.0311226, 1.0], + [0.0, 1.0, 1.0], + [1.6623358, 0.67162013, 1.0], + [-0.014613975, 0.90694416, 1.0], + [-0.7910066, 0.9813947, 1.0], + [-0.7020828, 0.9796464, 1.0], + [0.0, 1.0, 1.0], + [3.2134006, 0.70449984, 1.0], + [0.69320697, 0.9216747, 1.0], + [-0.55953246, 0.98736876, 1.0], + [-0.60563326, 0.9877621, 1.0], + [0.0, 1.0, 1.0], + [4.672629, 0.72619575, 1.0], + [2.541607, 0.90983087, 1.0], + [1.9858508, 0.9788352, 1.0], + [0.5622153, 1.0007318, 1.0], + [0.0, 1.0, 1.0], + [6.4891405, 0.7448458, 1.0], + [4.03939, 0.9308739, 1.0], + [3.3720996, 0.97321606, 1.0], + [1.2299676, 0.9903418, 1.0], + [0.0, 1.0, 1.0], + [7.157809, 0.77485734, 1.0], + [4.3740115, 0.9462917, 1.0], + [3.2057083, 0.98681796, 1.0], + [0.7071888, 0.997923, 1.0], + [0.0, 1.0, 1.0], + [7.228337, 0.79360247, 1.0], + [2.7674537, 0.9669471, 1.0], + [1.3489184, 1.003461, 1.0], + [0.16082476, 1.0005132, 1.0], + [0.0, 1.0, 1.0], + [6.9538918, 0.81647366, 1.0], + [3.1088212, 0.9820907, 1.0], + [1.4730394, 1.0125114, 1.0], + [0.15418816, 1.0018836, 1.0], + [0.0, 1.0, 1.0], + [7.6807427, 0.8534504, 1.0], + [3.718583, 0.99722385, 1.0], + [1.4714887, 1.0191232, 1.0], + [0.16044605, 1.0014175, 1.0], + [0.0, 1.0, 1.0], + [6.902218, 0.8794101, 1.0], + [2.855522, 0.99446875, 1.0], + [1.4610727, 1.0151895, 1.0], + [0.11758561, 1.0017471, 1.0], + [0.0, 1.0, 1.0], + [7.0196548, 0.9160186, 1.0], + [1.6999657, 1.0058115, 1.0], + [1.0062673, 1.0163534, 1.0], + [0.08308377, 1.0012561, 1.0], + [0.0, 1.0, 1.0], + [6.268169, 0.93658304, 1.0], + [1.035491, 0.99574167, 1.0], + [0.41364503, 1.0079995, 1.0], + [0.040540703, 1.0006597, 1.0], + [0.0, 1.0, 1.0], + [5.259043, 0.92028683, 1.0], + [0.23776771, 0.9797173, 1.0], + [0.07063755, 0.9973912, 1.0], + [0.066322915, 1.0000901, 1.0], + [0.0, 1.0, 1.0], + [6.786019, 0.91943693, 1.0], + [-0.12373619, 0.97899324, 1.0], + [-1.2265637, 0.9915, 1.0], + [-0.047353376, 0.9991457, 1.0], + [0.0, 1.0, 1.0], + [6.188807, 0.89457834, 1.0], + [-0.7516189, 0.9425688, 1.0], + [-1.1165174, 0.962824, 1.0], + [0.15711758, 0.99334264, 1.0], + [0.0, 1.0, 1.0], + [7.1094146, 0.89115375, 1.0], + [-0.14875633, 0.9282804, 1.0], + [-0.99777395, 0.94288254, 1.0], + [-0.13148743, 0.9870557, 1.0], + [0.0, 1.0, 1.0], + [6.441057, 0.90025866, 1.0], + [-0.48353556, 0.9186272, 1.0], + [-0.82087964, 0.9264453, 1.0], + [0.08318026, 0.9731093, 1.0], + [0.0, 1.0, 1.0], + [6.7111974, 0.91075236, 1.0], + [-0.36840874, 0.92250305, 1.0], + [-0.4315016, 0.9269158, 1.0], + [0.2359939, 0.96514857, 1.0], + [0.0, 1.0, 1.0], + [7.3763676, 0.92495906, 1.0], + [0.36888885, 0.9462593, 1.0], + [-1.5960634, 0.9405903, 1.0], + [-0.54044867, 0.9806632, 1.0], + [0.0, 1.0, 1.0], + [7.855471, 0.9369563, 1.0], + [1.1207947, 0.95559967, 1.0], + [-0.42048588, 0.9605789, 1.0], + [0.09410473, 0.9890609, 1.0], + [0.0, 1.0, 1.0], + [6.971675, 0.9428375, 1.0], + [1.4723355, 0.95907015, 1.0], + [-0.40449092, 0.9526683, 1.0], + [-0.034233358, 0.98458976, 1.0], + [0.0, 1.0, 1.0], + [6.2168164, 0.96651506, 1.0], + [0.45716965, 0.969795, 1.0], + [-1.276185, 0.95871323, 1.0], + [-0.46196827, 0.98386484, 1.0], + [0.0, 1.0, 1.0], + [1.5696334, 0.88579595, 1.0], + [-4.8797174, 0.8235008, 1.0], + [-5.7836246, 0.81873804, 1.0], + [-1.2262843, 0.9544453, 1.0], + [0.0, 1.0, 1.0], + [0.3305974, 0.86768496, 1.0], + [-4.0607495, 0.7667695, 1.0], + [-5.33198, 0.72661614, 1.0], + [-2.8205767, 0.83346885, 1.0], + [0.0, 1.0, 1.0], + [-0.10735662, 0.7961712, 1.0], + [-2.5164855, 0.746526, 1.0], + [-3.905786, 0.70082253, 1.0], + [-2.8722813, 0.7402977, 1.0], + [0.0, 1.0, 1.0], + [1.0955659, 0.7785365, 1.0], + [-0.020220347, 0.7376785, 1.0], + [-1.4722649, 0.72488576, 1.0], + [-1.4273844, 0.7533604, 1.0], + [0.0, 1.0, 1.0], + [3.1442819, 0.74805653, 1.0], + [2.5943675, 0.7122985, 1.0], + [1.4479681, 0.70843244, 1.0], + [-0.06824569, 0.74796575, 1.0], + [0.0, 1.0, 1.0], + [3.0809562, 0.7545235, 1.0], + [2.7876127, 0.7176543, 1.0], + [3.0311806, 0.6859141, 1.0], + [2.3895133, 0.7123276, 1.0], + [0.0, 1.0, 1.0], + [1.9816447, 0.78059196, 1.0], + [2.157276, 0.7259703, 1.0], + [2.2583017, 0.69609505, 1.0], + [2.6869667, 0.69099945, 1.0], + [0.0, 1.0, 1.0], + [1.1339029, 0.8082483, 1.0], + [0.9635001, 0.7438238, 1.0], + [1.7119975, 0.69262445, 1.0], + [2.8751085, 0.67264295, 1.0], + [0.0, 1.0, 1.0], + [0.7148004, 0.8236315, 1.0], + [0.15944664, 0.7895265, 1.0], + [0.8037349, 0.73090047, 1.0], + [2.2817705, 0.700999, 1.0], + [0.0, 1.0, 1.0], + [1.1721781, 0.823198, 1.0], + [0.42141455, 0.79766697, 1.0], + [1.731092, 0.72331715, 1.0], + [1.9070219, 0.70068294, 1.0], + [0.0, 1.0, 1.0], + [1.4472144, 0.82568, 1.0], + [0.7194572, 0.78889567, 1.0], + [1.2272654, 0.7160339, 1.0], + [1.3908615, 0.6934153, 1.0], + [0.0, 1.0, 1.0], + [0.9659478, 0.8547631, 1.0], + [0.23514794, 0.8048065, 1.0], + [0.6698278, 0.720812, 1.0], + [1.0716225, 0.6872872, 1.0], + [0.0, 1.0, 1.0], + [0.3646527, 0.8849565, 1.0], + [-0.07247575, 0.8116052, 1.0], + [0.0033967285, 0.72046185, 1.0], + [-0.12690859, 0.66787803, 1.0], + [0.0, 1.0, 1.0], + [0.14792149, 0.90996736, 1.0], + [-0.41231233, 0.8411673, 1.0], + [-0.4463926, 0.7333069, 1.0], + [-0.42133877, 0.6662905, 1.0], + [0.0, 1.0, 1.0], + [-0.5877023, 0.92537576, 1.0], + [-0.7669311, 0.85629356, 1.0], + [-0.6382856, 0.7815932, 1.0], + [-0.5130515, 0.72520864, 1.0], + [0.0, 1.0, 1.0], + [-0.8632642, 0.935545, 1.0], + [-0.6218721, 0.8610767, 1.0], + [-0.2218913, 0.7949077, 1.0], + [-0.026698561, 0.76374143, 1.0], + [0.0, 1.0, 1.0], + [-0.6670934, 0.95232236, 1.0], + [-0.013086532, 0.8753517, 1.0], + [0.3907465, 0.82373375, 1.0], + [0.6438301, 0.7755369, 1.0], + [0.0, 1.0, 1.0], + [-0.8246577, 0.96011007, 1.0], + [-0.022377672, 0.9073638, 1.0], + [0.5644851, 0.86494243, 1.0], + [1.0286213, 0.8235526, 1.0], + [0.0, 1.0, 1.0], + [-1.3516122, 0.97745264, 1.0], + [-0.5598576, 0.9293583, 1.0], + [0.04228649, 0.8763748, 1.0], + [-0.31584078, 0.8592634, 1.0], + [0.0, 1.0, 1.0], + [-1.9975094, 0.9925349, 1.0], + [-1.3282233, 0.9415561, 1.0], + [-0.52022403, 0.85210764, 1.0], + [-0.5763454, 0.8441975, 1.0], + [0.0, 1.0, 1.0], + [-3.0044324, 0.993117, 1.0], + [-2.32693, 0.9048576, 1.0], + [-1.0574396, 0.8305523, 1.0], + [-0.25919157, 0.86520964, 1.0], + [0.0, 1.0, 1.0], + [-2.8471406, 0.9966514, 1.0], + [-2.3174558, 0.8910772, 1.0], + [-1.6867507, 0.8372742, 1.0], + [-0.6674814, 0.9019048, 1.0], + [0.0, 1.0, 1.0], + [-2.7654781, 1.0002338, 1.0], + [-2.2775855, 0.92181945, 1.0], + [-1.6766036, 0.8592095, 1.0], + [-0.6071752, 0.91706604, 1.0], + [0.0, 1.0, 1.0], + [-3.2180955, 1.0019648, 1.0], + [-2.4868007, 0.9530428, 1.0], + [-0.8147237, 0.9659948, 1.0], + [-0.08990055, 0.99272156, 1.0], + [0.0, 1.0, 1.0], + [-2.6169603, 1.0069607, 1.0], + [-1.80826, 0.9822141, 1.0], + [-0.27318954, 0.99557036, 1.0], + [-0.0078226, 0.9997451, 1.0], + [0.0, 1.0, 1.0], + [-2.620088, 1.0105745, 1.0], + [-1.6558734, 0.99095327, 1.0], + [-0.34937736, 0.99785286, 1.0], + [-0.009377529, 0.9999541, 1.0], + [0.0, 1.0, 1.0], + [-1.9161261, 1.0054843, 1.0], + [-0.7893424, 0.9937573, 1.0], + [-0.21549447, 0.9983831, 1.0], + [-0.006033252, 0.9999624, 1.0], + [0.0, 1.0, 1.0], + [-1.6860873, 1.000248, 1.0], + [-0.8517966, 0.99048835, 1.0], + [-0.1809431, 0.99742275, 1.0], + [-0.0035661473, 0.99995184, 1.0], + [0.0, 1.0, 1.0], + [-2.009457, 0.9998743, 1.0], + [-1.0168654, 0.9889097, 1.0], + [-0.14624618, 0.99738944, 1.0], + [-0.002028748, 0.99996185, 1.0], + [0.0, 1.0, 1.0], + [-1.343951, 0.9905693, 1.0], + [-0.7694108, 0.988736, 1.0], + [-0.13042787, 0.9973714, 1.0], + [-0.0019758502, 0.9999585, 1.0], + [0.0, 1.0, 1.0], + [-1.5010524, 0.9900646, 1.0], + [-0.6511199, 0.99298286, 1.0], + [-0.05297016, 0.9991895, 1.0], + [-0.0008825612, 0.99998635, 1.0], + [0.0, 1.0, 1.0], + [-1.6744328, 0.9952045, 1.0], + [-0.56495047, 0.9939041, 1.0], + [-0.072879314, 0.9991123, 1.0], + [-0.0022557385, 0.9999713, 1.0], + [0.0, 1.0, 1.0], + [-2.817171, 0.98676676, 1.0], + [-0.59550554, 0.9955451, 1.0], + [-0.10011909, 0.99888563, 1.0], + [-0.002785918, 0.9999608, 1.0], + [0.0, 1.0, 1.0], + [-4.859452, 0.9669093, 1.0], + [-0.5635375, 0.9964578, 1.0], + [-0.061867803, 0.99939847, 1.0], + [-0.001733142, 0.9999818, 1.0], + [0.0, 1.0, 1.0], + [-6.3632984, 0.9476491, 1.0], + [-0.9620065, 0.9895864, 1.0], + [-0.1275121, 0.9982268, 1.0], + [-0.0027327028, 0.9999624, 1.0], + [0.0, 1.0, 1.0], + [-3.8316839, 0.9652471, 1.0], + [-1.5537338, 0.9825404, 1.0], + [-0.27127823, 0.99667627, 1.0], + [-0.0039444612, 0.9999499, 1.0], + [0.0, 1.0, 1.0], + [-2.9406507, 0.9717847, 1.0], + [-1.1054865, 0.98933583, 1.0], + [-0.15259248, 0.9985821, 1.0], + [-0.0023587716, 0.99997854, 1.0], + [0.0, 1.0, 1.0], + [-4.1605053, 0.9520869, 1.0], + [-2.1564174, 0.9791306, 1.0], + [-0.33962235, 0.9967707, 1.0], + [-0.005252303, 0.9999497, 1.0], + [0.0, 1.0, 1.0], + [-4.6772366, 0.9365187, 1.0], + [-0.94710606, 0.98695683, 1.0], + [-0.11579239, 0.9980016, 1.0], + [-0.002743927, 0.9999484, 1.0], + [0.0, 1.0, 1.0], + [-5.730075, 0.9172761, 1.0], + [-2.2775342, 0.96206695, 1.0], + [-0.38358852, 0.9927294, 1.0], + [-0.0075762877, 0.9998489, 1.0], + [0.0, 1.0, 1.0], + [-6.438858, 0.8943925, 1.0], + [-5.09395, 0.91621476, 1.0], + [-1.3278813, 0.9786671, 1.0], + [-0.026160287, 0.99958307, 1.0], + [0.0, 1.0, 1.0], + [-8.272365, 0.8478823, 1.0], + [-3.386477, 0.9320077, 1.0], + [-0.5339506, 0.9900594, 1.0], + [-0.008624411, 0.9998474, 1.0], + [0.0, 1.0, 1.0], + [-11.802094, 0.75678855, 1.0], + [-6.5117326, 0.85963863, 1.0], + [-1.7044662, 0.96734977, 1.0], + [-0.04367834, 0.9992392, 1.0], + [0.0, 1.0, 1.0], + [-15.306793, 0.6631, 1.0], + [-9.06385, 0.7773203, 1.0], + [-2.7547972, 0.9315435, 1.0], + [-0.10010718, 0.99756825, 1.0], + [0.0, 1.0, 1.0], + [-14.635688, 0.62598675, 1.0], + [-11.576207, 0.6989409, 1.0], + [-7.9337134, 0.8109356, 1.0], + [-0.6527108, 0.98375565, 1.0], + [0.0, 1.0, 1.0], + [-9.552264, 0.68059266, 1.0], + [-8.43511, 0.7298391, 1.0], + [-8.363759, 0.7697578, 1.0], + [-3.363505, 0.9041366, 1.0], + [0.0, 1.0, 1.0], + [-7.9862127, 0.70823747, 1.0], + [-6.549984, 0.7803926, 1.0], + [-6.411847, 0.8035285, 1.0], + [-5.4986544, 0.83412504, 1.0], + [0.0, 1.0, 1.0], + [-6.795965, 0.7171654, 1.0], + [-4.852691, 0.821311, 1.0], + [-4.071751, 0.8528317, 1.0], + [-2.6617806, 0.89110637, 1.0], + [0.0, 1.0, 1.0], + [-5.3123918, 0.72757715, 1.0], + [-3.218556, 0.8593564, 1.0], + [-1.1645839, 0.93264043, 1.0], + [0.039813682, 0.9652358, 1.0], + [0.0, 1.0, 1.0], + [-3.6778567, 0.6624857, 1.0], + [-1.7741417, 0.8541198, 1.0], + [-0.0029635795, 0.9721248, 1.0], + [0.40605798, 0.98661965, 1.0], + [0.0, 1.0, 1.0], + [-0.8761976, 0.6101054, 1.0], + [-0.49433082, 0.8107166, 1.0], + [-0.104019575, 0.86723596, 1.0], + [0.20899503, 0.890897, 1.0], + [0.0, 1.0, 1.0], + [2.861542, 0.67296326, 1.0], + [0.71851164, 0.8735976, 1.0], + [0.8827842, 0.8889713, 1.0], + [1.2020328, 0.8868588, 1.0], + [0.0, 1.0, 1.0], + [5.1083093, 0.72884905, 1.0], + [1.4813685, 0.8916017, 1.0], + [0.98923063, 0.90779537, 1.0], + [1.0078008, 0.9088085, 1.0], + [0.0, 1.0, 1.0], + [5.061599, 0.7781577, 1.0], + [6.068938, 0.85700047, 1.0], + [7.827683, 0.85164875, 1.0], + [3.5587494, 0.9304464, 1.0], + [0.0, 1.0, 1.0], + [5.0541573, 0.80283755, 1.0], + [5.1185517, 0.9080198, 1.0], + [6.060745, 0.91160166, 1.0], + [2.6937423, 0.9596598, 1.0], + [0.0, 1.0, 1.0], + [4.3760943, 0.8454239, 1.0], + [2.5560715, 0.9567565, 1.0], + [2.1207442, 0.9821871, 1.0], + [0.37868842, 0.99643296, 1.0], + [0.0, 1.0, 1.0], + [3.3878245, 0.8679573, 1.0], + [0.71156067, 0.96918815, 1.0], + [0.12126909, 0.99451196, 1.0], + [0.001029497, 0.9995172, 1.0], + [0.0, 1.0, 1.0], + [3.1282337, 0.8924943, 1.0], + [0.5896895, 0.9759098, 1.0], + [-0.11373672, 0.9960285, 1.0], + [-0.01838675, 0.9997154, 1.0], + [0.0, 1.0, 1.0], + [4.3400493, 0.90476185, 1.0], + [1.3310212, 0.9786638, 1.0], + [0.17455858, 0.99855655, 1.0], + [0.005317117, 0.9999772, 1.0], + [0.0, 1.0, 1.0], + [2.8491004, 0.93242216, 1.0], + [1.6193472, 0.9691039, 1.0], + [0.80294114, 0.99088013, 1.0], + [0.029686294, 0.9997005, 1.0], + [0.0, 1.0, 1.0], + [1.4401128, 0.9450957, 1.0], + [-0.16860908, 0.97805417, 1.0], + [0.08968142, 0.9966332, 1.0], + [0.0058796364, 0.9999138, 1.0], + [0.0, 1.0, 1.0], + [0.9945528, 0.91562444, 1.0], + [-1.0134145, 0.97015774, 1.0], + [-0.3692183, 0.9947299, 1.0], + [-0.007456935, 0.9999008, 1.0], + [0.0, 1.0, 1.0], + [0.055624425, 0.9145302, 1.0], + [-2.5425062, 0.9648608, 1.0], + [-1.0486484, 0.98947704, 1.0], + [-0.02597243, 0.99974424, 1.0], + [0.0, 1.0, 1.0], + [1.5285127, 0.9355288, 1.0], + [-2.3404818, 0.95899427, 1.0], + [-1.5345422, 0.98380965, 1.0], + [-0.061189, 0.9995191, 1.0], + [0.0, 1.0, 1.0], + [0.72061044, 0.92197156, 1.0], + [-5.383721, 0.90637255, 1.0], + [-3.6513355, 0.9478104, 1.0], + [-0.16841647, 0.9974061, 1.0], + [0.0, 1.0, 1.0], + [-0.028428238, 0.89694136, 1.0], + [-7.034104, 0.86039716, 1.0], + [-6.6405563, 0.88451445, 1.0], + [-0.60662454, 0.98856294, 1.0], + [0.0, 1.0, 1.0], + [-1.0993353, 0.8637165, 1.0], + [-7.684664, 0.8347192, 1.0], + [-6.927881, 0.8624654, 1.0], + [-0.7485936, 0.98060685, 1.0], + [0.0, 1.0, 1.0], + [-0.6461796, 0.892773, 1.0], + [-7.967004, 0.8294017, 1.0], + [-6.8060684, 0.8550583, 1.0], + [-0.8756036, 0.9734578, 1.0], + [0.0, 1.0, 1.0], + [0.017491894, 0.8894634, 1.0], + [-13.43903, 0.7625003, 1.0], + [-16.398203, 0.7407526, 1.0], + [-4.292457, 0.92504275, 1.0], + [0.0, 1.0, 1.0], + [1.3580081, 0.8969149, 1.0], + [-12.114263, 0.7745682, 1.0], + [-15.785243, 0.74346626, 1.0], + [-3.6444035, 0.9348789, 1.0], + [0.0, 1.0, 1.0], + [1.7327255, 0.87856543, 1.0], + [-6.4373803, 0.8177134, 1.0], + [-10.325781, 0.78358805, 1.0], + [-2.567262, 0.9422052, 1.0], + [0.0, 1.0, 1.0], + [1.7113199, 0.8661993, 1.0], + [-6.172294, 0.80224586, 1.0], + [-8.819174, 0.79800993, 1.0], + [-2.020759, 0.95133585, 1.0], + [0.0, 1.0, 1.0], + [-0.265015, 0.9794298, 1.0], + [-2.8535793, 0.9150202, 1.0], + [-2.305115, 0.93263686, 1.0], + [-0.16463748, 0.9945547, 1.0], + [0.0, 1.0, 1.0], + [-0.040856205, 0.9830346, 1.0], + [-1.0551267, 0.93554235, 1.0], + [-1.7591288, 0.8933596, 1.0], + [-0.34586504, 0.97479594, 1.0], + [0.0, 1.0, 1.0], + [0.04883148, 0.94569665, 1.0], + [-0.56813735, 0.9045709, 1.0], + [-1.5370053, 0.81085134, 1.0], + [-0.67263573, 0.8751091, 1.0], + [0.0, 1.0, 1.0], + [0.3425455, 0.9003105, 1.0], + [0.7451464, 0.8549796, 1.0], + [0.20236868, 0.8441968, 1.0], + [0.07073531, 0.86383015, 1.0], + [0.0, 1.0, 1.0], + [2.8022106, 0.80535203, 1.0], + [4.376988, 0.70769286, 1.0], + [4.8166633, 0.707951, 1.0], + [2.8132818, 0.8092864, 1.0], + [0.0, 1.0, 1.0], + [2.3843334, 0.85017675, 1.0], + [5.65875, 0.6995908, 1.0], + [8.184944, 0.62843776, 1.0], + [5.3796787, 0.7383771, 1.0], + [0.0, 1.0, 1.0], + [0.9017272, 0.9307022, 1.0], + [3.9063222, 0.765117, 1.0], + [6.974823, 0.69017345, 1.0], + [8.185112, 0.6926818, 1.0], + [0.0, 1.0, 1.0], + [0.34200728, 0.96810126, 1.0], + [2.172312, 0.84994847, 1.0], + [8.207728, 0.66688955, 1.0], + [10.739886, 0.62480503, 1.0], + [0.0, 1.0, 1.0], + [0.2027068, 0.9796112, 1.0], + [0.44695723, 0.9623203, 1.0], + [2.7991126, 0.8646282, 1.0], + [5.550562, 0.77532023, 1.0], + [0.0, 1.0, 1.0], + [0.27983877, 0.96956384, 1.0], + [0.464609, 0.9587286, 1.0], + [2.1685085, 0.8807419, 1.0], + [3.9939811, 0.8027935, 1.0], + [0.0, 1.0, 1.0], + [0.32165113, 0.96697015, 1.0], + [0.6435883, 0.93713695, 1.0], + [2.400229, 0.8268258, 1.0], + [3.7155714, 0.7548948, 1.0], + [0.0, 1.0, 1.0], + [0.13139115, 0.98465353, 1.0], + [0.26204187, 0.9602435, 1.0], + [1.2602012, 0.87366205, 1.0], + [2.4989963, 0.7911792, 1.0], + [0.0, 1.0, 1.0], + [0.04954044, 0.990862, 1.0], + [0.15550658, 0.9640937, 1.0], + [0.25354654, 0.9320879, 1.0], + [0.37589833, 0.8832426, 1.0], + [0.0, 1.0, 1.0], + [0.045748647, 0.99577636, 1.0], + [0.049044535, 0.9851228, 1.0], + [0.006159843, 0.9524085, 1.0], + [0.02588024, 0.8944849, 1.0], + [0.0, 1.0, 1.0], + [0.0030355332, 0.99710935, 1.0], + [-0.011863776, 0.9914036, 1.0], + [-0.054379225, 0.972176, 1.0], + [-0.039397053, 0.9642003, 1.0], + [0.0, 1.0, 1.0], + [-0.008150871, 0.9936041, 1.0], + [-0.037190154, 0.9887421, 1.0], + [-0.06706805, 0.96539193, 1.0], + [-0.0317106, 0.9736436, 1.0], + [0.0, 1.0, 1.0], + [-0.0109262755, 0.9985575, 1.0], + [-0.024314428, 0.99118114, 1.0], + [-0.047992434, 0.9725764, 1.0], + [-0.012975532, 0.9771226, 1.0], + [0.0, 1.0, 1.0], + [-0.011895402, 0.99871415, 1.0], + [-0.05900639, 0.9952044, 1.0], + [-0.07541064, 0.9911421, 1.0], + [-0.010232872, 0.99422073, 1.0], + [0.0, 1.0, 1.0], + [-0.014660832, 0.9972283, 1.0], + [-0.028494045, 0.9946739, 1.0], + [-0.04470135, 0.9915807, 1.0], + [-0.08405579, 0.9945791, 1.0], + [0.0, 1.0, 1.0], + [-0.033616997, 0.9969934, 1.0], + [-0.045168266, 0.993965, 1.0], + [-0.059918057, 0.98309493, 1.0], + [-0.062201336, 0.98883307, 1.0], + [0.0, 1.0, 1.0], + [-0.2381658, 0.99073696, 1.0], + [-0.47134387, 0.97534996, 1.0], + [-0.15076974, 0.978513, 1.0], + [-0.027898252, 0.9923376, 1.0], + [0.0, 1.0, 1.0], + [-0.094118536, 0.99575883, 1.0], + [-0.22728153, 0.9851976, 1.0], + [-0.14446811, 0.98079014, 1.0], + [-0.026381236, 0.9947049, 1.0], + [0.0, 1.0, 1.0], + [-0.027151769, 0.9987402, 1.0], + [-0.055821914, 0.9954352, 1.0], + [-0.07118323, 0.9875545, 1.0], + [-0.024681333, 0.9936937, 1.0], + [0.0, 1.0, 1.0], + [-0.057406988, 0.9985795, 1.0], + [-0.075127326, 0.99687934, 1.0], + [-0.012029578, 0.99905646, 1.0], + [-0.0011061644, 0.99975455, 1.0], + [0.0, 1.0, 1.0], + [-0.02290934, 0.99975824, 1.0], + [-0.025390672, 0.9995626, 1.0], + [-0.0026628294, 0.99994296, 1.0], + [-3.556243e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.017105369, 0.99992365, 1.0], + [-0.01626235, 0.9998567, 1.0], + [-0.0023494267, 0.9999825, 1.0], + [-3.9371847e-05, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.01157836, 0.999962, 1.0], + [-0.002812421, 0.99997854, 1.0], + [-0.0007163898, 0.9999961, 1.0], + [-1.7496699e-05, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.014018655, 0.99996704, 1.0], + [-0.002332822, 0.9999732, 1.0], + [-0.0003527423, 0.99999386, 1.0], + [-5.771106e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.010408213, 0.99997354, 1.0], + [-0.0040600216, 0.99995077, 1.0], + [-0.0005498676, 0.999989, 1.0], + [-7.1796394e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.0023898028, 0.9999025, 1.0], + [-0.0021971867, 0.99995613, 1.0], + [-0.00040569165, 0.99999076, 1.0], + [-5.7337793e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.006176083, 0.99988633, 1.0], + [-0.0033466935, 0.99995404, 1.0], + [-0.00018771198, 0.99999684, 1.0], + [-1.5582656e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.0078279935, 0.99997926, 1.0], + [-0.0021727988, 0.99997437, 1.0], + [-8.390261e-05, 0.9999989, 1.0], + [-7.6212564e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.011409035, 0.9999373, 1.0], + [-0.0012485707, 0.99999255, 1.0], + [-5.873547e-05, 0.99999946, 1.0], + [-1.1412731e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.0320137, 0.9996883, 1.0], + [-0.0022234167, 0.99998236, 1.0], + [-4.2978718e-05, 0.99999964, 1.0], + [-8.9091026e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.060883675, 0.99925774, 1.0], + [-0.0041872207, 0.9999481, 1.0], + [-0.00016332162, 0.99999785, 1.0], + [-2.8002216e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.026953986, 0.999662, 1.0], + [-0.00503322, 0.9999406, 1.0], + [-0.0005006954, 0.9999944, 1.0], + [-6.1892397e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.019095117, 0.9997323, 1.0], + [-0.0048538097, 0.9999398, 1.0], + [-0.00037457192, 0.9999961, 1.0], + [-3.6742229e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.03751212, 0.9993693, 1.0], + [-0.010522507, 0.99987054, 1.0], + [-0.0009128494, 0.99999034, 1.0], + [-7.887352e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.05509656, 0.99895823, 1.0], + [-0.005975121, 0.99990004, 1.0], + [-0.00018607976, 0.9999967, 1.0], + [-2.4801245e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.06763289, 0.9990122, 1.0], + [-0.026763257, 0.99962497, 1.0], + [-0.0033132737, 0.9999459, 1.0], + [-5.9110884e-05, 0.999999, 1.0], + [0.0, 1.0, 1.0], + [-0.06290056, 0.9987752, 1.0], + [-0.06590056, 0.9988833, 1.0], + [-0.016112784, 0.9997368, 1.0], + [-0.00030407612, 0.9999951, 1.0], + [0.0, 1.0, 1.0], + [-0.06050088, 0.9985156, 1.0], + [-0.017029036, 0.99951345, 1.0], + [-0.0026492565, 0.9999436, 1.0], + [-4.364142e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-0.21668494, 0.99283016, 1.0], + [-0.19338678, 0.9937941, 1.0], + [-0.029703936, 0.99916244, 1.0], + [-0.00055253773, 0.9999868, 1.0], + [0.0, 1.0, 1.0], + [-1.0463094, 0.95176995, 1.0], + [-1.3973116, 0.9536363, 1.0], + [-0.50282496, 0.98618114, 1.0], + [-0.019256245, 0.9995079, 1.0], + [0.0, 1.0, 1.0], + [-3.5435355, 0.84623945, 1.0], + [-5.1535788, 0.84832674, 1.0], + [-2.824056, 0.9316529, 1.0], + [-0.15195742, 0.9963698, 1.0], + [0.0, 1.0, 1.0], + [-8.673897, 0.7131587, 1.0], + [-12.270074, 0.68430734, 1.0], + [-11.376253, 0.73719627, 1.0], + [-1.8281991, 0.95513356, 1.0], + [0.0, 1.0, 1.0], + [-12.039047, 0.6432662, 1.0], + [-13.724231, 0.6535759, 1.0], + [-14.0326, 0.6799867, 1.0], + [-9.946063, 0.780004, 1.0], + [0.0, 1.0, 1.0], + [-12.528839, 0.6221673, 1.0], + [-13.425968, 0.65002877, 1.0], + [-13.075199, 0.67871696, 1.0], + [-10.942493, 0.7442244, 1.0], + [0.0, 1.0, 1.0], + [-10.306666, 0.6389583, 1.0], + [-12.0551195, 0.66896164, 1.0], + [-9.823601, 0.7308576, 1.0], + [-5.281826, 0.83818936, 1.0], + [0.0, 1.0, 1.0], + [-5.3867707, 0.65209216, 1.0], + [-5.8347864, 0.71964204, 1.0], + [-3.59081, 0.8329669, 1.0], + [-1.4847257, 0.9156909, 1.0], + [0.0, 1.0, 1.0], + [-1.754784, 0.74886227, 1.0], + [-0.24797672, 0.74751395, 1.0], + [0.6299296, 0.7613501, 1.0], + [0.82416815, 0.8632212, 1.0], + [0.0, 1.0, 1.0], + [1.4033656, 0.9101428, 1.0], + [2.4547875, 0.81642723, 1.0], + [3.6370852, 0.787311, 1.0], + [3.6531272, 0.8031764, 1.0], + [0.0, 1.0, 1.0], + [1.9459922, 0.951957, 1.0], + [2.5553524, 0.8894862, 1.0], + [3.6345732, 0.81872904, 1.0], + [2.785436, 0.8552696, 1.0], + [0.0, 1.0, 1.0], + [0.679637, 0.9787555, 1.0], + [3.8709927, 0.9172711, 1.0], + [5.7068896, 0.8849159, 1.0], + [1.1118813, 0.9758798, 1.0], + [0.0, 1.0, 1.0], + [0.3420551, 0.98117673, 1.0], + [1.5517482, 0.967525, 1.0], + [2.653233, 0.95270234, 1.0], + [0.44305915, 0.9921006, 1.0], + [0.0, 1.0, 1.0], + [0.23155965, 0.9924181, 1.0], + [0.11397146, 0.99741286, 1.0], + [0.13187227, 0.9980809, 1.0], + [0.020392587, 0.9996969, 1.0], + [0.0, 1.0, 1.0], + [0.13352406, 0.99550617, 1.0], + [0.025518132, 0.99892914, 1.0], + [-0.0005152045, 0.99990207, 1.0], + [-0.000118601536, 0.99999446, 1.0], + [0.0, 1.0, 1.0], + [0.15706417, 0.99707514, 1.0], + [0.02578307, 0.9993154, 1.0], + [-0.00202729, 0.9999331, 1.0], + [-0.00021436776, 0.9999964, 1.0], + [0.0, 1.0, 1.0], + [0.3615966, 0.99366856, 1.0], + [0.087646805, 0.99867254, 1.0], + [0.002637883, 0.99996406, 1.0], + [3.6361645e-05, 0.99999946, 1.0], + [0.0, 1.0, 1.0], + [0.1291499, 0.9977887, 1.0], + [0.046300918, 0.99929285, 1.0], + [0.012817254, 0.9998544, 1.0], + [0.00041612334, 0.9999955, 1.0], + [0.0, 1.0, 1.0], + [0.011200566, 0.9983723, 1.0], + [-0.008214572, 0.99942863, 1.0], + [0.0021838315, 0.9999416, 1.0], + [0.000100621306, 0.9999986, 1.0], + [0.0, 1.0, 1.0], + [-0.060381968, 0.9930565, 1.0], + [-0.06464544, 0.9983384, 1.0], + [-0.007711393, 0.99990016, 1.0], + [-0.00011417703, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.11693042, 0.99224305, 1.0], + [-0.11525121, 0.9982498, 1.0], + [-0.027416473, 0.99979055, 1.0], + [-0.00054621015, 0.99999607, 1.0], + [0.0, 1.0, 1.0], + [0.014975694, 0.99728876, 1.0], + [-0.056130975, 0.9989796, 1.0], + [-0.0222607, 0.9997437, 1.0], + [-0.00058228534, 0.9999938, 1.0], + [0.0, 1.0, 1.0], + [0.05060913, 0.99668014, 1.0], + [-0.2700596, 0.99520206, 1.0], + [-0.14083444, 0.9978208, 1.0], + [-0.005215654, 0.9999189, 1.0], + [0.0, 1.0, 1.0], + [-0.05793456, 0.99310505, 1.0], + [-1.3339288, 0.97664934, 1.0], + [-1.0737243, 0.98243177, 1.0], + [-0.06070526, 0.9990094, 1.0], + [0.0, 1.0, 1.0], + [-0.22500885, 0.9885798, 1.0], + [-2.394239, 0.9580366, 1.0], + [-1.8970352, 0.9690168, 1.0], + [-0.109246016, 0.99819875, 1.0], + [0.0, 1.0, 1.0], + [-0.39491686, 0.98909, 1.0], + [-2.9813097, 0.9401541, 1.0], + [-2.3468347, 0.9552055, 1.0], + [-0.15643519, 0.99701655, 1.0], + [0.0, 1.0, 1.0], + [-1.8292645, 0.9594883, 1.0], + [-13.511781, 0.763661, 1.0], + [-14.536935, 0.7543218, 1.0], + [-2.2238204, 0.95809615, 1.0], + [0.0, 1.0, 1.0], + [-1.0053811, 0.97643167, 1.0], + [-12.578439, 0.77343625, 1.0], + [-15.1597395, 0.73498416, 1.0], + [-2.625323, 0.9481359, 1.0], + [0.0, 1.0, 1.0], + [-0.55154705, 0.9794314, 1.0], + [-6.9208384, 0.85034823, 1.0], + [-10.053682, 0.79368883, 1.0], + [-1.6742866, 0.96229243, 1.0], + [0.0, 1.0, 1.0], + [-0.69403476, 0.96975285, 1.0], + [-3.2779868, 0.9127137, 1.0], + [-4.176942, 0.9027185, 1.0], + [-0.5743538, 0.98639596, 1.0] + ], + "srgb_value": true + } +} diff --git a/contributors/people.toml b/contributors/people.toml index 27a8c8d9cb..bec67597c2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -25,9 +25,23 @@ sync_github_name = false [people.rock-meister] sync_github_name = true +[people.Th4nat0s] +display_name = "Thanat0s" + [people.sanyamsingh001] display_name = "sanyamsingh001" [people.ArekatlaNishanthchowdary] real_name = "Arekatla Nishanth Chowdary" sync_github_name = true + +[people.Losminek] +sync_github_name = true + +[people.FooVoo] +real_name = "Aleksandr Batyrev" +sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/catalog/proptest-regressions/tests_index.txt b/crates/catalog/proptest-regressions/tests_index.txt new file mode 100644 index 0000000000..9c827f0c03 --- /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 0000000000..2c347473bc --- /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/journal.rs b/crates/catalog/src/journal.rs index 98fd616a38..289ee4fb8d 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -51,6 +51,7 @@ //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | //! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | +//! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -75,7 +76,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 8; +pub const VERSION: u32 = 9; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index ed4cb75b41..1776c895c1 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; @@ -25,6 +26,7 @@ pub mod rules; pub mod safe_file; pub mod stacks; pub mod store; +pub mod tally; use std::collections::BTreeMap; use std::sync::Arc; @@ -214,6 +216,10 @@ pub enum Op { /// `None` = its raw data decodes (or it isn't a raw). #[serde(default, skip_serializing_if = "Option::is_none")] preview_only: Option, + /// The app version that decided `preview_only` (see [`Photo::preview_checked`]); empty + /// when it is `None`. + #[serde(default, skip_serializing_if = "String::is_empty")] + preview_checked: String, }, /// The lens data a photo's file carries (what Reload finds when it was read after import). SetEmbeddedLens { @@ -281,6 +287,74 @@ pub struct Catalog { /// Increments on every applied op. #[serde(skip)] pub revision: u64, + /// 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 +/// rating changes no folder's count; a slider changes none at all). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Aspects(u16); + +impl Aspects { + pub const NONE: Aspects = Aspects(0); + pub const RATING: Aspects = Aspects(1); + pub const FLAG: Aspects = Aspects(1 << 1); + pub const LABEL: Aspects = Aspects(1 << 2); + /// Develop settings, versions and history. + pub const DEVELOP: Aspects = Aspects(1 << 3); + /// Title, caption, keywords, people, place and the rest of the metadata. + pub const META: Aspects = Aspects(1 << 4); + pub const CAPTURED: Aspects = Aspects(1 << 5); + /// Where its file is. + pub const FILE: Aspects = Aspects(1 << 6); + pub const ANALYSIS: Aspects = Aspects(1 << 7); + /// Whether it is in the library at all: added, removed, deleted, restored, taken in from Local. + pub const PRESENCE: Aspects = Aspects(1 << 8); + /// Everything: the photo may count differently for any tally. + pub const ALL: Aspects = Aspects(u16::MAX); + + pub const fn with(self, other: Aspects) -> Aspects { + Aspects(self.0 | other.0) + } + + /// Whether any of `other` is among these. + pub const fn any_of(self, other: Aspects) -> bool { + self.0 & other.0 != 0 + } +} + +/// The most photos remembered as touched; older changes are then answered with "anything may have changed". +const MAX_TOUCHED: usize = 8192; + +/// A short memory of what the latest ops changed, so that what is derived from the catalog (the grid's list) can +/// follow a small edit without being worked out again from every photo. +#[derive(Clone, Debug, Default)] +struct Changes { + /// The photos ops changed, each with the revision the op made and what of the photo it changed. Oldest first. + touched: std::collections::VecDeque<(u64, PhotoId, Aspects)>, + /// What the op being applied changes of its photo. + now: Aspects, + /// The latest revision made by an op that changed more than photos of its own (an album, a stack, a keyword of + /// the list…): what it changed for which photos is not known. + broad: u64, + /// Changes up to this revision are no longer all in `touched`. + floor: u64, + /// The revision the last op recorded here made. When the catalog's revision is another, it was set from + /// outside (a library just loaded takes a range of its own, so that nothing kept from another library is + /// taken for this one's): nothing is known of how the catalog got to where it is. + seen: u64, +} + +impl PartialEq for Changes { + /// Two catalogs are the same catalog whatever each remembers of how it got there. + fn eq(&self, _: &Self) -> bool { + true + } } impl Catalog { @@ -365,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. @@ -419,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(), } @@ -429,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, } @@ -479,20 +564,110 @@ impl Catalog { // ---- writes fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> { + self.touch(id); self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id)) } + + /// The op being applied changes photo `id` (or adds or removes it). + fn touch(&mut self, id: PhotoId) { + // (the revision the op will make: `apply` counts up once it is done) + self.changes.touched.push_back((self.revision.saturating_add(1), id, self.changes.now)); + while self.changes.touched.len() > MAX_TOUCHED { + if let Some((revision, _, _)) = self.changes.touched.pop_front() { + self.changes.floor = revision; + } + } + } + + /// What of its photos the ops since `revision` changed, taken together ([`Aspects::NONE`] when there were + /// none). `None` when that is not known ([`Catalog::touched_since`]). A tally of the library that reads only + /// other things of a photo is still right. + pub fn aspects_since(&self, revision: u64) -> Option { + if !self.remembers(revision) { + return None; + } + Some(self.changes.touched.iter().filter(|(r, _, _)| *r > revision).fold(Aspects::NONE, |all, (_, _, a)| all.with(*a))) + } + + /// Whether every change since the catalog was at `revision` is remembered photo by photo. + fn remembers(&self, revision: u64) -> bool { + self.changes.seen == self.revision && revision <= self.revision && revision >= self.changes.floor && self.changes.broad <= revision + } + + /// The photos changed, added or removed since the catalog was at `revision`, each once. `None` when that is not + /// known: an op since then changed more than photos of its own, or it was too long ago. Then anything may + /// have changed. + pub fn touched_since(&self, revision: u64) -> Option> { + if !self.remembers(revision) { + return None; + } + let mut ids: Vec = self.changes.touched.iter().filter(|(r, _, _)| *r > revision).map(|(_, id, _)| *id).collect(); + ids.sort_unstable(); + ids.dedup(); + Some(ids) + } fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> { self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id)) } /// Apply an op; returns its inverse. On error nothing changes. pub fn apply(&mut self, op: Op) -> Result { + if self.changes.seen != self.revision { + // the revision was set from outside: what is remembered is of another past + self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() }; + } let inv = self.apply_inner(op)?; self.revision += 1; + self.changes.seen = self.revision; Ok(inv) } fn apply_inner(&mut self, op: Op) -> Result { + // An op that changes one photo says which (`photo_mut`, and adding or removing it below). Every other kind + // can change what any photo matches or where it sorts: an album's photos or rules, a stack, a keyword's + // name. Listed kind by kind, so a new one has to be put on one side. + match &op { + // (a photo that comes, goes, or changes what it is to the library counts for every tally; so do the + // rare kinds whose reach nobody has to think about) + Op::AddPhoto { photo } => { + self.changes.now = Aspects::ALL; + self.touch(photo.id); + } + Op::RemovePhoto { id } => { + self.changes.now = Aspects::ALL; + self.touch(*id); + } + Op::SetDeleted { .. } | Op::SetLocal { .. } | Op::SetContent { .. } | Op::SetEmbeddedLens { .. } | Op::SetAsShotWb { .. } => { + self.changes.now = Aspects::ALL + } + Op::SetRating { .. } => self.changes.now = Aspects::RATING, + Op::SetFlag { .. } => self.changes.now = Aspects::FLAG, + Op::SetLabel { .. } => self.changes.now = Aspects::LABEL, + Op::SetMeta { .. } => self.changes.now = Aspects::META, + Op::SetCaptured { .. } => self.changes.now = Aspects::CAPTURED, + Op::SetFile { .. } | Op::Relink { .. } => self.changes.now = Aspects::FILE, + Op::SetAnalysis { .. } => self.changes.now = Aspects::ANALYSIS, + Op::SetDevelop { .. } | Op::SetVersions { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetHistoryCursor { .. } => { + self.changes.now = Aspects::DEVELOP + } + // (its ops say for themselves, one by one) + Op::Batch { .. } => {} + Op::AddAlbum { .. } + | Op::RemoveAlbum { .. } + | Op::RenameAlbum { .. } + | Op::MoveAlbum { .. } + | Op::SetAlbumOrder { .. } + | Op::SetAlbumPhotos { .. } + | Op::SetAlbumCover { .. } + | Op::SetAlbumRules { .. } + | Op::AddStack { .. } + | Op::RemoveStack { .. } + | Op::SetStack { .. } + | Op::SetLabelName { .. } + | Op::SetBrowsed { .. } + | Op::SetKeyword { .. } + | Op::SetFolderRecord { .. } => self.changes.broad = self.revision.saturating_add(1), + } Ok(match op { Op::AddPhoto { photo } => { if self.photos.contains_key(&photo.id) { @@ -590,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 } } @@ -598,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 } => { @@ -637,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)?; @@ -664,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 } => { @@ -677,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 } @@ -700,7 +884,7 @@ impl Catalog { let old_format = format.map(|f| std::mem::replace(&mut p.format, f)); Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format } } - Op::SetContent { id, width, height, file_size, content_hash, preview_only } => { + Op::SetContent { id, width, height, file_size, content_hash, preview_only, preview_checked } => { let p = self.photo_mut(id)?; Op::SetContent { id, @@ -709,6 +893,7 @@ impl Catalog { file_size: std::mem::replace(&mut p.file_size, file_size), content_hash: std::mem::replace(&mut p.content_hash, content_hash), preview_only: std::mem::replace(&mut p.preview_only, preview_only), + preview_checked: std::mem::replace(&mut p.preview_checked, preview_checked), } } Op::SetEmbeddedLens { id, lens } => { @@ -914,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 } @@ -963,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; @@ -971,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/local.rs b/crates/catalog/src/local.rs index 9cfc14f356..093c224271 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -183,15 +183,7 @@ impl Catalog { let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() }; let now_s = crate::stacks::iso_seconds(now); let max_age = i64::from(days) * 86_400; - let mut referenced: HashSet = HashSet::new(); - for a in self.albums.values() { - referenced.extend(a.photos.iter().copied()); - referenced.extend(a.cover); - } - for s in self.stacks.values() { - referenced.extend(s.photos.iter().copied()); - } - referenced.extend(self.photos.values().filter_map(|p| p.copy_of)); + let referenced = self.referenced(); // the fingerprints are the costly part: work them out for the candidates in parallel let candidates: Vec<&Photo> = self .photos @@ -237,6 +229,30 @@ impl Catalog { plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect(); plan } + + /// Of `ids`, the untouched Local records nothing refers to (no album, stack or virtual copy): + /// the ones [`Catalog::forget_local_plan`] could forget, whatever their folder's time. + pub fn forgettable_local(&self, ids: &[PhotoId]) -> Vec { + let referenced = self.referenced(); + let candidates: Vec<&Photo> = + ids.iter().filter(|id| !referenced.contains(id)).filter_map(|id| self.photos.get(id)).filter(|p| p.local).map(|p| &**p).collect(); + let untouched = untouched_of(&candidates); + ids.iter().copied().filter(|id| untouched.contains(id)).collect() + } + + /// Photos an album (as a member or its cover), a stack or a virtual copy refers to. + fn referenced(&self) -> HashSet { + let mut referenced: HashSet = HashSet::new(); + for a in self.albums.values() { + referenced.extend(a.photos.iter().copied()); + referenced.extend(a.cover); + } + for s in self.stacks.values() { + referenced.extend(s.photos.iter().copied()); + } + referenced.extend(self.photos.values().filter_map(|p| p.copy_of)); + referenced + } } /// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable). diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 434ea3a1e3..0c7ca51123 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -199,6 +199,11 @@ pub struct Meta { /// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub regions: Vec, + /// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony + /// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never + /// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -293,6 +298,12 @@ pub struct Photo { /// picture style baked in — so it is treated as a rendered (non-raw) source. #[serde(default, skip_serializing_if = "Option::is_none")] pub preview_only: Option, + /// The app version (`CARGO_PKG_VERSION`) whose decoder set [`Photo::preview_only`]; empty when + /// it isn't set, and in catalogs from before format v9. A preview-only photo whose stamp + /// differs from the running version is rechecked when its library opens (issue #662): newer + /// builds may decode the file, older ones may decode less. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub preview_checked: String, /// A Local record: the fingerprint of its state when the browse catalogued it, to tell /// whether the user changed anything since (see [`crate::local`]). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -318,7 +329,7 @@ pub struct Analysis { /// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera: /// [`relative_wb_camera`]. pub fn relative_wb_format(format: &str) -> bool { - ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f)) + ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f)) } /// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed @@ -332,6 +343,16 @@ pub fn relative_wb_camera(format: &str, camera: &str) -> bool { relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera) } +/// This build's version: what a photo's [`Photo::preview_checked`] stamp is compared with. Every +/// workspace crate shares one version, so it is the app's. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The [`Photo::preview_checked`] stamp for a `preview_only` decided by this build: its version +/// when the photo is preview-only, empty otherwise. +pub fn preview_stamp(preview_only: &Option) -> String { + if preview_only.is_some() { APP_VERSION.to_string() } else { String::new() } +} + impl Photo { /// The History step the settings are at (the cursor, clamped to the list; `None` = no history). pub fn history_index(&self) -> Option { @@ -371,9 +392,20 @@ impl Photo { import_look: None, analysis: None, preview_only: None, + preview_checked: String::new(), local_baseline: None, } } + /// Sets [`Photo::preview_only`] as this build decided it, with its stamp. + pub fn set_preview_only(&mut self, preview_only: Option) { + self.preview_checked = preview_stamp(&preview_only); + self.preview_only = preview_only; + } + /// A preview-only photo whose flag was set by another app version (or before stamps existed), + /// so this build should probe its file again (issue #662). + pub fn preview_only_stale(&self) -> bool { + self.preview_only.is_some() && self.preview_checked != APP_VERSION + } /// A raw file developed from its sensor data: not a rendered image, and not a raw shown from /// its embedded preview ([`Photo::preview_only`]). pub fn develops_raw(&self) -> bool { @@ -577,6 +609,7 @@ mod edited_tests { ("a.cr2", "CR2", true), ("a.pef", "pef", true), ("a.orf", "ORF", true), + ("a.erf", "ERF", true), ("a.dng", "DNG", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); @@ -609,6 +642,8 @@ mod edited_tests { ("SONY ILCE-7RM4A", "ARW", false), ("NIKON CORPORATION NIKON D850", "NEF", false), ("Canon Canon EOS 5D Mark III", "CR2", false), + ("Canon Canon EOS R6m2", "CR3", false), + ("Canon Canon EOS R7", "CR3", true), ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), ("SONY ILCE-7M2", "ARW", true), @@ -624,7 +659,11 @@ mod edited_tests { p.as_shot_wb = Some((4986.0, -2.0)); assert_eq!(p.relative_wb(), relative, "{camera} {format}"); assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}"); - assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}"); + assert_eq!( + p.fitted_look(), + format == "ARW" || format == "NEF" || format == "CR2" || format == "CR3" || format == "RAF", + "{camera} {format}" + ); let wb = p.camera_defaults().wb; assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}"); assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}"); diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index a9956703dc..d1548f8bdf 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -94,6 +94,27 @@ pub struct Sort { pub seed: u64, } +impl Sort { + /// Which of two photos comes first in this order (never `Equal` for two photos: the id decides last). + pub fn order(&self, a: &Photo, b: &Photo) -> std::cmp::Ordering { + let o = match self.key { + SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), + SortKey::ImportDate => a.imported.cmp(&b.imported), + SortKey::EditDate => a.edited.cmp(&b.edited), + SortKey::FileName => cmp_name(&a.file_name, &b.file_name), + SortKey::Rating => a.rating.cmp(&b.rating), + SortKey::FileSize => a.file_size.cmp(&b.file_size), + SortKey::Random => shuffle_rank(self.seed, a.id).cmp(&shuffle_rank(self.seed, b.id)), + } + // Ties (one import batch, a burst within a second, equal ratings) sort by file name, the + // order cameras number frames, so the order doesn't depend on how ids were allocated; + // the id only keeps it stable. + .then_with(|| if self.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) }) + .then(a.id.cmp(&b.id)); + if self.ascending { o } else { o.reverse() } + } +} + impl Default for Sort { fn default() -> Self { Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 } @@ -114,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(':') { @@ -236,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. @@ -246,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; } @@ -287,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 { @@ -327,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; @@ -369,38 +407,66 @@ pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering { impl Catalog { /// Photos matching `filter`, in `sort` order (ties broken by file name, then id). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec { - let root = filter.library_root(); - let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); - v.sort_by(|a, b| { - let o = match sort.key { - SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), - SortKey::ImportDate => a.imported.cmp(&b.imported), - SortKey::EditDate => a.edited.cmp(&b.edited), - SortKey::FileName => cmp_name(&a.file_name, &b.file_name), - SortKey::Rating => a.rating.cmp(&b.rating), - SortKey::FileSize => a.file_size.cmp(&b.file_size), - SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)), - } - // Ties (one import batch, a burst within a second, equal ratings) sort by file name, the - // order cameras number frames, so the order doesn't depend on how ids were allocated; - // the id only keeps it stable. - .then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) }) - .then(a.id.cmp(&b.id)); - if sort.ascending { o } else { o.reverse() } - }); + self.query_keeping(filter, sort, &std::collections::HashSet::new(), filter) + } + + /// [`Catalog::query`], and with them the photos of `keep` that still match `within`, each at its place in `sort` + /// order: a view that holds the photos it showed when an edit makes them stop matching `filter` (a smart + /// 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 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, &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() } + /// The list `was` (a [`Catalog::query`] of `filter` in `sort` order, or a [`Catalog::query_keeping`]), after ops + /// that touched only the photos `touched` (sorted by id, each once: [`Catalog::touched_since`]): those photos + /// taken out, and the ones that match `filter` now put back at their place in the order. With `within`, a + /// touched photo that was in the list and still matches `within` stays, as `query_keeping` would keep it. What + /// the query would give, without asking every photo again. + 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 (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.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); + } + } + ids + } + + /// How many photos match `filter`: [`Catalog::query`]'s length without its sorting. + pub fn count(&self, filter: &Filter) -> usize { + 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). pub fn date_groups(&self) -> Vec { use std::collections::BTreeMap; let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new(); for p in self.photos().filter(|p| p.in_library()) { let Some(d) = p.captured.as_deref() else { continue }; - if d.len() >= 10 { - let (months, days) = years.entry(&d[..4]).or_default(); - *months.entry(&d[..7]).or_default() += 1; - *days.entry(&d[..10]).or_default() += 1; + // `get`, not `[..]`: a corrupt or foreign `captured` (a Lightroom catalog's + // captureTime is copied verbatim) may not be ASCII, and slicing it on a byte index + // would panic — the same rule as `dates::group_label`. + if let (Some(year), Some(month), Some(day)) = (d.get(..4), d.get(..7), d.get(..10)) { + let (months, days) = years.entry(year).or_default(); + *months.entry(month).or_default() += 1; + *days.entry(day).or_default() += 1; } } let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::>(); @@ -441,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 f17f12e014..a87c9effc0 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 6ffb11f52c..a713b77361 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(()) @@ -231,6 +231,60 @@ impl Catalog { } } +impl Catalog { + /// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge + /// results), then the unstacked ones in capture order, a set running while each starts within about a second of the + /// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at + /// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out. + pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec> { + let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some(); + let mut sets: Vec> = Vec::new(); + let mut stacks = HashSet::new(); + let mut loose = Vec::new(); + for id in ids { + match self.stack_of(*id) { + Some(s) if stacks.insert(s.id) => { + let set: Vec = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect(); + if set.len() >= 2 { + sets.push(set); + } + } + Some(_) => {} + None => loose.push(*id), + } + } + // (capture second, file name, id, shutter seconds, EV) + let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose + .iter() + .filter_map(|id| self.photos.get(id)) + .filter(|p| !p.deleted && !merged(p)) + .filter_map(|p| { + let t = iso_seconds(p.captured.as_deref()?)?; + let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?; + let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64; + let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64; + Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2())) + }) + .collect(); + timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2))); + timed.dedup_by_key(|x| x.2); + let mut runs: Vec> = Vec::new(); + for x in timed { + match runs.last_mut() { + Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x), + _ => runs.push(vec![x]), + } + } + for r in runs { + let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4))); + if r.len() >= 2 && hi - lo >= 0.5 { + sets.push(r.iter().map(|x| x.2).collect()); + } + } + sets + } +} + /// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`, /// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse. pub fn iso_seconds(s: &str) -> Option { @@ -358,6 +412,46 @@ mod tests { assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id()); } + #[test] + fn bracket_sets_by_time_and_exposure() { + // (time, shutter, ISO, file name) + let shots = [ + ("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket + ("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"), + ("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"), + ("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it + ("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket + ("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"), + ("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure + ("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"), + ("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart + ("2026-06-27T23:35:01", "2", 100, "N2.ARW"), + ("2026-06-27T23:35:04", "2", 800, "N3.ARW"), + ("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times + ("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"), + ]; + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for (t, sh, iso, name) in shots { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some(t.into()); + p.meta.shutter = sh.into(); + p.meta.iso = Some(iso); + p.meta.aperture = Some(5.6); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + let op = c.group_ops(ids[11], &[ids[12]], true).unwrap(); + c.apply(op).unwrap(); + let sets = c.bracket_sets(&ids); + assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]); + // a stack's merge result on top is left out; a stack selected twice is merged once + let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]); + } + #[test] fn auto_stack_by_capture_time() { let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"]; diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs index a672d00e7b..2f3dc4912b 100644 --- a/crates/catalog/src/store.rs +++ b/crates/catalog/src/store.rs @@ -73,6 +73,16 @@ impl FsStore { } } +/// A new staging file at `tmp`: whatever is already there (a leftover from a crashed save, or a link) is removed first +/// (a link itself, never what it points to), then the file is created exclusively, so a write never follows a link +/// planted at that name into another file (issue #1502). +fn create_staging(tmp: &std::path::Path) -> io::Result { + if std::fs::symlink_metadata(tmp).is_ok() { + std::fs::remove_file(tmp)?; + } + std::fs::OpenOptions::new().write(true).create_new(true).open(tmp) +} + impl Store for FsStore { fn read(&mut self, name: &str) -> io::Result>> { match std::fs::read(self.path(name)) { @@ -89,7 +99,7 @@ impl Store for FsStore { } let tmp = self.path(&format!("{name}.tmp")); { - let mut f = std::fs::File::create(&tmp)?; + let mut f = create_staging(&tmp)?; f.write_all(data)?; f.sync_all()?; } @@ -105,7 +115,7 @@ impl Store for FsStore { } let tmp = self.path(&format!("{name}.tmp")); let written = (|| { - let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 }; + let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, create_staging(&tmp)?), n: 0 }; fill(&mut w)?; w.flush()?; let f = w.inner.into_inner().map_err(|e| e.into_error())?; @@ -231,3 +241,29 @@ impl Store for MemStore { self.background.then(|| Box::new(self.clone()) as Box) } } + +#[cfg(test)] +mod tests_staging { + use super::*; + + /// A link planted at a staging name (`.tmp`) is removed, never followed: the file it points to keeps its + /// bytes and the save still lands (issue #1502). + #[cfg(unix)] + #[test] + fn a_link_at_the_staging_name_is_never_written_through() { + let dir = std::env::temp_dir().join(format!("lc-staging-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut store = FsStore::open(&dir).unwrap(); + let victim = dir.join("victim.txt"); + std::fs::write(&victim, b"keep").unwrap(); + std::os::unix::fs::symlink(&victim, dir.join("catalog.snap.tmp")).unwrap(); + store.write_atomic("catalog.snap", b"{}").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep"); + assert_eq!(std::fs::read(dir.join("catalog.snap")).unwrap(), b"{}"); + std::os::unix::fs::symlink(&victim, dir.join("catalog.log.tmp")).unwrap(); + store.write_atomic_with("catalog.log", &mut |w| w.write_all(b"[1]")).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep"); + assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), b"[1]"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/catalog/src/tally.rs b/crates/catalog/src/tally.rs new file mode 100644 index 0000000000..8f15e5ec46 --- /dev/null +++ b/crates/catalog/src/tally.rs @@ -0,0 +1,234 @@ +//! How many photos each album shows, kept up to date from what the latest ops touched. +//! +//! A smart album's count asks every photo whether its rules hold. Asked again after every edit, that is a pass over +//! the library for each smart album each time a keyword is given. [`AlbumTally`] remembers which photos each smart +//! album holds, and after a small edit asks only the photos the edit touched ([`Catalog::touched_since`]). An op +//! that changes more than photos of its own (an album's rules or photos, a stack, the keyword list) has it counted +//! whole again, as before. + +use std::collections::{HashMap, HashSet}; + +use crate::{AlbumId, Aspects, Catalog, PhotoId}; + +/// The most photos an edit may touch for the counts to follow it photo by photo. +const MAX_FOLLOWED: usize = 256; +/// Bits are kept while they cost no more than this many words a photo held, and these to start with. Past that +/// (photo ids far apart: a library whose ids don't count up from 1) a set is kept instead, so what an album +/// remembers is always in proportion to the photos it holds, never to the largest id in the library. +const WORDS_A_MEMBER: usize = 4; +const WORDS_TO_START: usize = 1024; + +/// The photos a smart album holds. +#[derive(Clone, Debug)] +enum Members { + /// One bit a photo id, and how many are set. + Bits { + words: Vec, + len: usize, + }, + Set(HashSet), +} + +impl Members { + fn none() -> Members { + Members::Bits { words: Vec::new(), len: 0 } + } + + fn len(&self) -> usize { + match self { + Members::Bits { len, .. } => *len, + Members::Set(set) => set.len(), + } + } + + #[cfg(test)] + fn holds(&self, id: PhotoId) -> bool { + match self { + Members::Bits { words, .. } => { + usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0) + } + Members::Set(set) => set.contains(&id), + } + } + + /// Put `id` in or take it out. + fn set(&mut self, id: PhotoId, held: bool) { + if let Members::Bits { words, len } = self { + let bit = 1u64 << (id.0 % 64); + match usize::try_from(id.0 / 64) { + Ok(at) if at < words.len() => { + if let Some(word) = words.get_mut(at) { + match (*word & bit != 0, held) { + (false, true) => { + *word |= bit; + *len = len.saturating_add(1); + } + (true, false) => { + *word &= !bit; + *len = len.saturating_sub(1); + } + _ => {} + } + } + return; + } + // (past the end: not held, so there is nothing to take out) + _ if !held => return, + // room for it, as long as the bits stay in proportion to the photos held + Ok(at) if at < len.saturating_mul(WORDS_A_MEMBER).saturating_add(WORDS_TO_START) => { + words.resize(at + 1, 0); + if let Some(word) = words.get_mut(at) { + *word |= bit; + *len = len.saturating_add(1); + } + return; + } + // ids too far apart for bits: a set from here on + _ => { + let ids = words + .iter() + .enumerate() + .flat_map(|(at, word)| (0..64u64).filter(move |b| word & (1u64 << b) != 0).map(move |b| PhotoId(at as u64 * 64 + b))); + *self = Members::Set(ids.collect()); + } + } + } + if let Members::Set(set) = self { + if held { + set.insert(id); + } else { + set.remove(&id); + } + } + } +} + +/// [`Catalog::album_count`] of every album, as of one revision of the catalog. +#[derive(Clone, Debug)] +pub struct AlbumTally { + revision: u64, + counts: HashMap, + /// The smart albums' photos. + members: HashMap, +} + +impl AlbumTally { + /// Counted from every photo. + pub fn of(cat: &Catalog) -> AlbumTally { + let mut counts = HashMap::new(); + let mut members = HashMap::new(); + for album in cat.albums() { + if album.is_smart() { + let mut held = Members::none(); + for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) { + held.set(p.id, true); + } + counts.insert(album.id, held.len()); + members.insert(album.id, held); + } else { + counts.insert(album.id, cat.album_count(album.id)); + } + } + AlbumTally { revision: cat.revision, counts, members } + } + + /// The count of each album. + pub fn counts(&self) -> &HashMap { + &self.counts + } + + /// The catalog's revision the counts are of. + pub fn revision(&self) -> u64 { + self.revision + } + + /// Bring the counts up to `cat` as it is now. `true` when that was done from the photos the ops since touched + /// (or nothing had changed), `false` when every photo was asked again. + /// + /// `cat` has to be the catalog the counts were made of, further along. Another catalog is told apart only by + /// its revisions: a library that is loaded takes a range of its own (and then everything is counted again), but + /// two catalogs both counting from 0 look like one. + pub fn update(&mut self, cat: &Catalog) -> bool { + if self.revision == cat.revision { + return true; + } + let followed = match (cat.touched_since(self.revision), cat.aspects_since(self.revision)) { + (Some(touched), Some(changed)) if touched.len() <= MAX_FOLLOWED => { + self.follow(cat, &touched, changed); + true + } + _ => false, + }; + if !followed { + *self = AlbumTally::of(cat); + } + followed + } + + /// The counts after ops that touched only `touched`: each smart album asks those photos again; an album of + /// chosen photos counts again only when a photo came, went or was deleted (who is in it did not change: that + /// is an op on the album, and not followed). + fn follow(&mut self, cat: &Catalog, touched: &[PhotoId], changed: Aspects) { + for (album, held) in &mut self.members { + for id in touched { + held.set(*id, cat.photo(*id).is_some_and(|p| cat.album_contains(*album, p))); + } + self.counts.insert(*album, held.len()); + } + if changed.any_of(Aspects::PRESENCE) { + for album in cat.albums().filter(|a| !a.is_smart()) { + self.counts.insert(album.id, cat.album_count(album.id)); + } + } + self.revision = cat.revision; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(m: &Members, of: &[u64]) -> Vec { + of.iter().copied().filter(|id| m.holds(PhotoId(*id))).collect() + } + + #[test] + fn members_are_kept_as_bits_at_the_edges_of_a_word_and_past_the_end() { + let mut m = Members::none(); + let probe = [0, 1, 63, 64, 65, 127, 128, 5000]; + assert!(ids(&m, &probe).is_empty() && m.len() == 0); + for id in [0, 63, 64, 5000] { + m.set(PhotoId(id), true); + } + assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 64, 5000], 4)); + // put in twice, taken out twice, taken out where nothing is, far past the end: counted once each way + m.set(PhotoId(63), true); + m.set(PhotoId(64), false); + m.set(PhotoId(64), false); + m.set(PhotoId(1), false); + m.set(PhotoId(900_000), false); + assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 5000], 3)); + assert!(matches!(m, Members::Bits { .. })); + } + + #[test] + fn photo_ids_far_apart_are_kept_as_a_set_in_proportion_to_the_photos_held() { + // one photo with an id in the thousands of millions: no bit for every id up to it + let mut m = Members::none(); + for id in [3, 70, 9_000_000_000] { + m.set(PhotoId(id), true); + } + assert!(matches!(m, Members::Set(_)), "a set once the ids are too far apart for bits"); + assert_eq!((ids(&m, &[3, 70, 71, 9_000_000_000, u64::MAX]), m.len()), (vec![3, 70, 9_000_000_000], 3)); + m.set(PhotoId(u64::MAX), true); + m.set(PhotoId(3), false); + assert_eq!((ids(&m, &[3, 70, 9_000_000_000, u64::MAX]), m.len()), (vec![70, 9_000_000_000, u64::MAX], 3)); + // an album of many photos with ids counting up stays bits, however many + let mut dense = Members::none(); + for id in 1..=200_000u64 { + dense.set(PhotoId(id), true); + } + assert!(matches!(&dense, Members::Bits { words, .. } if words.len() <= 200_000 / 64 + 1)); + assert_eq!(dense.len(), 200_000); + } +} diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb19..8f1aaf1514 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -123,6 +123,23 @@ fn filter_search_sort() { assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]); } +/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an +/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be +/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores +/// the rest instead of panicking on a char boundary. +#[test] +fn date_groups_ignores_a_non_ascii_captured() { + let mut c = Catalog::new(); + let ok = photo(&mut c, "ok.jpg", "2026-05-01T10:00:00"); + // `AddPhoto` takes the field as it is, like `library.importLightroom` building the op + let bad = photo(&mut c, "bad.jpg", "日本語日本語"); + assert_ne!(ok, bad); + let g = c.date_groups(); + assert_eq!(g.len(), 1, "the one well-formed date still groups"); + assert_eq!((g[0].year.as_str(), g[0].count), ("2026", 1)); + assert_eq!(g[0].days.iter().map(|d| d.1).sum::(), 1); +} + #[test] fn snapshot_and_log_replay() { let mut c = Catalog::new(); @@ -265,13 +282,30 @@ fn preview_only_survives_serde_and_set_content_undo() { let old: Catalog = serde_json::from_value(v).unwrap(); assert_eq!(old.photo(a).unwrap().preview_only, None); assert!(old.photo(a).unwrap().develops_raw()); - // Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it - let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None }; + // a newer build still can't decode it: the reason and the stamp of the version that decided + // it are recorded together (#662) + let op = Op::SetContent { + id: a, + width: 6000, + height: 4000, + file_size: 1, + content_hash: None, + preview_only: Some("Nikon Huffman-compressed NEF".into()), + preview_checked: "0.6.0".into(), + }; + c.apply(op).unwrap(); + assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0"); + // Reload: the file decodes now → cleared, with its stamp; undo (through the journal's JSON) + // restores both + let op = + Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None, preview_checked: String::new() }; let inv = c.apply(op).unwrap(); assert!(c.photo(a).unwrap().develops_raw()); + assert_eq!(c.photo(a).unwrap().preview_checked, ""); let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); c.apply(inv).unwrap(); assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF")); + assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0"); assert_eq!(c.photo(a).unwrap().width, 6000); } @@ -391,6 +425,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() { assert_eq!(back, with); } +/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a +/// catalog from before it existed reads with none, and writes the same), kept exactly when known. +#[test] +fn focus_point_is_not_serialized_when_unknown_and_round_trips() { + let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("focus").is_none(), "{v}"); + let back: Meta = serde_json::from_value(v).unwrap(); + assert!(back.focus.is_none()); + let mut with = m.clone(); + with.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: None, + }); + let json = serde_json::to_string(&with).unwrap(); + assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}"); + let back: Meta = serde_json::from_str(&json).unwrap(); + assert_eq!(back, with); +} + // ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos fn many(c: &mut Catalog, n: usize) -> Vec { @@ -737,3 +793,81 @@ fn cmp_name_ignores_case_without_allocating() { assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less); assert_eq!(cmp_name("a", "ab"), Less); } + +/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be +/// said photo by photo. +#[test] +fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() { + let mut c = Catalog::default(); + let photo = |c: &mut Catalog, name: &str| { + let id = c.alloc_photo_id(); + c.apply(Op::AddPhoto { + photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")), + }) + .unwrap(); + id + }; + let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg")); + let start = c.revision; + assert_eq!(c.touched_since(start), Some(vec![])); + assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); + // a rating, then a flag on another photo, as one batch + c.apply(Op::SetRating { id: a, rating: 3 }).unwrap(); + assert_eq!(c.touched_since(start), Some(vec![a])); + assert_eq!(c.aspects_since(start), Some(Aspects::RATING)); + let after_rating = c.revision; + c.apply(Op::Batch { ops: vec![Op::SetFlag { id: b, flag: Flag::Pick }, Op::SetRating { id: a, rating: 4 }] }).unwrap(); + assert_eq!(c.touched_since(start), Some(vec![a, b]), "each once"); + assert_eq!(c.touched_since(after_rating), Some(vec![a, b])); + assert_eq!(c.aspects_since(after_rating), Some(Aspects::FLAG.with(Aspects::RATING))); + assert_eq!(c.touched_since(c.revision), Some(vec![])); + // a photo that goes counts for everything + let before = c.revision; + c.apply(Op::RemovePhoto { id: b }).unwrap(); + assert_eq!(c.touched_since(before), Some(vec![b])); + assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::PRESENCE) && x.any_of(Aspects::FILE))); + // an op that changes more than photos of its own: nothing is said of what came before it + let before = c.revision; + let album = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap(); + assert_eq!(c.touched_since(before), None); + assert_eq!(c.aspects_since(start), None); + assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); + // a revision that is not this catalog's past + assert_eq!(c.touched_since(c.revision + 5), None); + // a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after + // the next op nothing of before it + let old = c.revision; + c.revision = 7 << 32; + assert_eq!(c.touched_since(old), None); + assert_eq!(c.aspects_since(c.revision), None); + let loaded = c.revision; + c.apply(Op::SetRating { id: a, rating: 1 }).unwrap(); + assert_eq!(c.touched_since(loaded), Some(vec![a])); + assert_eq!(c.touched_since(old), None); + // an op that fails changes nothing, and what is remembered never says less than what happened + let before = c.revision; + assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err()); + assert_eq!(c.revision, before); + c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap(); + assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG))); + // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch + // with an op that changes more than photos is not + let before = c.revision; + assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err()); + assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1))); + c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap(); + // (the photo the failed batch asked for and never found may be named too: more is said, never less) + assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + let before = c.revision; + c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap(); + assert_eq!(c.touched_since(before), None); + // more ops than are remembered: the oldest are answered with "anything" + let long_ago = c.revision; + for i in 0..9000u32 { + c.apply(Op::SetRating { id: a, rating: (i % 5) as u8 }).unwrap(); + } + assert_eq!(c.touched_since(long_ago), None); + assert_eq!(c.touched_since(c.revision - 10), Some(vec![a])); +} diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index e2461781a4..e31d7fe0c4 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -74,6 +74,29 @@ fn v3_library_loads_and_is_upgraded() { assert_eq!(snapshot_version(&m), u64::from(VERSION)); } +/// A v6 library (before preview-only photos recorded which version decided it, #662) opens with +/// an empty stamp on its preview-only photos, so they are rechecked once, and is rewritten in the +/// current format. +#[test] +fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() { + let mut c = Catalog::new(); + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: "/p/a.cr3".into() }, "a.cr3", "CR3", 160, 120, "2026-01-01"); + p.kind = MediaKind::Raw; + p.preview_only = Some("Cr3 files are not decoded yet".into()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let snap = c.to_snapshot(); + assert!(!snap.contains("preview_checked"), "an empty stamp isn't written"); + let m = MemStore::new(); + m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":6,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes()); + let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap(); + let p = loaded.photo(id).unwrap(); + assert_eq!(p.preview_only.as_deref(), Some("Cr3 files are not decoded yet")); + assert_eq!(p.preview_checked, ""); + assert_eq!(r.upgraded_from, Some(6)); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); +} + #[test] fn versionless_log_only_library_is_upgraded() { let mut c = Catalog::new(); @@ -171,7 +194,8 @@ fn bad_crc_is_still_a_torn_tail() { /// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks /// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the -/// variant here under the new version. +/// variant here under the new version. A version that only adds serialized fields raises +/// `NEWEST_FIELDS` instead. #[test] fn op_variants_are_versioned() { fn since(op: &Op) -> u32 { @@ -214,6 +238,8 @@ fn op_variants_are_versioned() { Op::SetHistoryCursor { .. } => 8, } } + /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`. + const NEWEST_FIELDS: u32 = 9; let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); - assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); + assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version"); } diff --git a/crates/catalog/src/tests_index.rs b/crates/catalog/src/tests_index.rs new file mode 100644 index 0000000000..f1f41501b0 --- /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_lock.rs b/crates/catalog/src/tests_lock.rs index d464d5c25a..baa8da6596 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -126,33 +126,40 @@ fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() { /// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires, /// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix -/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail. +/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, and on a +/// slow or loaded machine (the FreeBSD CI VM, where a spawn can take longer than that) until a few +/// children have been spawned during them, at most a minute. #[cfg(unix)] #[test] fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() { use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + /// Children spawned while the lock cycles, at least, before the test may stop. + const CHILDREN: usize = 5; let dir = temp_dir("spawn"); let stop = Arc::new(AtomicBool::new(false)); + let count = Arc::new(AtomicUsize::new(0)); let spawner = { - let stop = Arc::clone(&stop); + let (stop, count) = (Arc::clone(&stop), Arc::clone(&count)); std::thread::spawn(move || { - let mut spawned = 0usize; while !stop.load(Ordering::Relaxed) { if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { let _ = c.wait(); - spawned += 1; + count.fetch_add(1, Ordering::Relaxed); } } - spawned + count.load(Ordering::Relaxed) }) }; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let t0 = Instant::now(); + let enough = |cycles: usize| (cycles >= 200_000 || t0.elapsed() >= Duration::from_secs(2)) && count.load(Ordering::Relaxed) >= CHILDREN; let mut cycles = 0usize; let mut refused = Vec::new(); - while cycles < 200_000 && std::time::Instant::now() < deadline { + while !enough(cycles) && t0.elapsed() < Duration::from_secs(60) { match LibraryLock::acquire(&dir, "LightCraft") { Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back Err(e) => refused.push(format!("cycle {cycles}: {e:?}")), diff --git a/crates/catalog/src/tests_scale.rs b/crates/catalog/src/tests_scale.rs new file mode 100644 index 0000000000..81ddccc415 --- /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 166fbf4dcc..1ae016bc88 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/jpeg.rs b/crates/codecs/src/jpeg.rs index e40cf7e73c..b8de12bddc 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -75,14 +75,12 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { let end = (p + 2 + len).min(b.len()); let seg = &b[start..end]; match marker { - 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => { - if seg.len() >= 6 && m.components == 0 { - m.precision = seg[0]; - m.sof = marker; - m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32; - m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32; - m.components = seg[5]; - } + 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => { + m.precision = seg[0]; + m.sof = marker; + m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32; + m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32; + m.components = seg[5]; } 0xE1 => { if seg.starts_with(b"Exif\0\0") && m.exif.is_none() { @@ -182,9 +180,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: check_size(F, m.width as u64, m.height as u64, opts)?; let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2))); // zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK, - // 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling). + // 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling) + // and every lossless (SOF3) file, which zune-jpeg refuses outright. + let lossless = m.sof == 0xC3; let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; - let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved { + let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless { decode_jpeg_decoder(bytes, &m, scale_to)? } else { decode_zune(bytes, &m)? @@ -221,7 +221,9 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) use jpeg_decoder::PixelFormat; let mut d = jpeg_decoder::Decoder::new(bytes); d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?; - if let Some((mw, mh)) = scale_to { + // `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the + // output to the top-left of each plane. Those decode at full size; `finish` scales after. + if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) { // Ask for the smallest DCT scale still covering the target box. let (sw, sh) = scaled_request(m.width, m.height, mw, mh); d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?; @@ -231,16 +233,25 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) let (w, h) = (info.width as usize, info.height as usize); let (model, buf, depth) = match info.pixel_format { PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8), - PixelFormat::L16 => { - let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (Model::Gray, Buf::U16(v), 16) - } + PixelFormat::L16 => (Model::Gray, Buf::U16(lossless_samples(&px, m.precision)), 16), + // A lossless frame over 8 bits per sample still reports `RGB24` (or `CMYK32`), but its + // buffer holds two native-endian bytes per sample like L16. + PixelFormat::RGB24 if m.precision > 8 => (Model::Rgb, Buf::U16(lossless_samples(&px, m.precision)), 16), PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8), + PixelFormat::CMYK32 if m.precision > 8 => (Model::Cmyk, Buf::U16(lossless_samples(&px, m.precision)), 16), PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8), }; Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth }) } +/// jpeg-decoder's lossless samples: one native-endian `u16` per sample holding the raw +/// `precision`-bit value. Left-justify it into the 16-bit range the rest of the pipeline reads, +/// as every 16-bit decoder feeding [`Buf::U16`] does. +fn lossless_samples(px: &[u8], precision: u8) -> Vec { + let shift = 16u8.saturating_sub(precision); + px.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]]) << shift).collect() +} + /// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this /// is most of [`header`]'s time). fn find_ff(b: &[u8]) -> Option { @@ -450,4 +461,94 @@ mod tests { let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap(); assert!(m.exif.is_none()); } + + // --- lossless (SOF3) synthetic files: one DHT table with a single 1-bit category-0 code, so + // every pixel is a zero difference and decodes to the predictor's initial sample. No such + // encoder exists here; the bytes are the smallest a decoder accepts. + + fn lossless_header(out: &mut Vec, components: u8, w: u16, precision: u8) { + // DHT: one DC table (id 0) with a single 1-bit code for the category-0 symbol. + out.extend_from_slice(&[0xFF, 0xC4]); + out.extend_from_slice(&(2u16 + 1 + 16 + 1).to_be_bytes()); + out.push(0x00); + let mut counts = [0u8; 16]; + counts[0] = 1; + out.extend_from_slice(&counts); + out.push(0x00); + // SOF3 + out.extend_from_slice(&[0xFF, 0xC3]); + out.extend_from_slice(&(8u16 + 3 * components as u16).to_be_bytes()); + out.push(precision); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&w.to_be_bytes()); + out.push(components); + for c in 1..=components { + out.extend_from_slice(&[c, 0x11, 0]); + } + // SOS: predictor Ra, Se = 0, Ah/Al = 0 + out.extend_from_slice(&[0xFF, 0xDA]); + out.extend_from_slice(&(6u16 + 2 * components as u16).to_be_bytes()); + out.push(components); + for c in 1..=components { + out.extend_from_slice(&[c, 0]); + } + out.extend_from_slice(&[1, 0, 0]); + } + + /// A minimal lossless JPEG, `w` x 1, `components` samples per pixel, every sample the initial + /// prediction `1 << (precision - 1)`. + fn lossless_jpeg(components: u8, w: u16, precision: u8) -> Vec { + let mut out = vec![0xFF, 0xD8]; + lossless_header(&mut out, components, w, precision); + // entropy: one zero bit per sample (a category-0 code), padded to whole bytes + out.extend_from_slice(&vec![0u8; (components as usize * w as usize).div_ceil(8)]); + out.extend_from_slice(&[0xFF, 0xD9]); + out + } + + /// jpeg-decoder emits lossless samples native-endian (its `to_ne_bytes`) holding the raw + /// `precision`-bit value, so the old `from_be_bytes` swapped every sample (2048 -> 8) and the + /// raw value was never left-justified into the 16-bit buffer. + #[test] + fn lossless_grayscale_samples_are_native_endian_and_left_justified() { + let bytes = lossless_jpeg(1, 8, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified"); + assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16)); + } + + /// A lossless frame over 8 bits per sample reports `RGB24`, but its buffer is two bytes per + /// sample: it must become `Buf::U16`, not `Buf::U8` over double-length rows. + #[test] + fn lossless_high_precision_rgb_is_16_bit() { + let bytes = lossless_jpeg(3, 2, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 6]); + assert_eq!(raw.model, Model::Rgb); + } + + /// The same 16-bit wrapping for four components: `CMYK32` over 8 bits is two bytes a sample. + #[test] + fn lossless_high_precision_cmyk_is_16_bit() { + let bytes = lossless_jpeg(4, 2, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 8]); + assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16)); + } + + /// zune-jpeg refuses SOF3 ("Lossless Huffman"), so an 8-bit lossless file must route to + /// jpeg-decoder; `header` accepts it, so `decode` used to fail a file we listed as readable. + #[test] + fn eight_bit_lossless_jpeg_decodes() { + let bytes = lossless_jpeg(1, 8, 8); + let d = crate::decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (8, 1)); + assert_eq!(d.bit_depth, 8); + } } diff --git a/crates/codecs/src/jpeg_par.rs b/crates/codecs/src/jpeg_par.rs index 6ec9b6a800..c8d9e5023c 100644 --- a/crates/codecs/src/jpeg_par.rs +++ b/crates/codecs/src/jpeg_par.rs @@ -3,8 +3,11 @@ //! The image is cut into bands of MCU rows separated by restart markers (`DRI` + `RSTn`): DC //! prediction restarts at every marker, so bands are entropy-coded independently on all cores and //! concatenated. Quantization uses the example tables of T.81 Annex K scaled by the usual 1–100 -//! quality convention; Huffman coding uses the Annex K example tables (interleaved baseline, readable -//! by every decoder). Supports grayscale and YCbCr 4:4:4 / 4:2:0. +//! quality convention. Huffman tables are made for each image from its own symbol counts (T.81 +//! Annex K.2): a first pass over the bands counts the symbols, a second codes them. The file stays +//! interleaved baseline with the tables in its `DHT` segments, readable by every decoder, and is +//! smaller than with the Annex K example tables at the same pixels. Supports grayscale and YCbCr +//! 4:4:4 / 4:2:0. use crate::encode::ChromaSubsampling; @@ -61,16 +64,103 @@ impl Huff { let (mut c, mut k) = (0u16, 0usize); for (len, &n) in bits.iter().enumerate() { for _ in 0..n { - code[vals[k] as usize] = (c, len as u8 + 1); - c += 1; + if let Some(&v) = vals.get(k) { + code[v as usize] = (c, len as u8 + 1); + } + c = c.wrapping_add(1); k += 1; } - c <<= 1; + c = c.wrapping_shl(1); } Huff { code } } } +/// Symbol counts of one Huffman table: index = symbol (DC size category, or AC run/size byte). +type Freq = [u64; 256]; + +/// The code lengths (`BITS`, counts per length 1..16) and symbols (`HUFFVAL`) of an optimal +/// Huffman table for `freq`, limited to 16-bit codes and never assigning the all-ones code +/// (T.81 Annex K.2, figures K.1–K.4). Symbols with a zero count get no code. At least one symbol +/// is always present (a table nothing used gets symbol 0), so the result is a valid `DHT`. +fn optimal_table(freq: &Freq) -> ([u8; 16], Vec) { + // 256 is the reserved pseudo-symbol: counted once, it takes the longest code, which is + // then dropped so no real code is all ones (K.2) + let mut f = [0u64; 257]; + f[..256].copy_from_slice(freq); + if f[..256].iter().all(|&c| c == 0) { + f[0] = 1; + } + f[256] = 1; + let mut size = [0usize; 257]; + let mut next: [Option; 257] = [None; 257]; + loop { + // the two smallest non-zero counts; on equal counts the larger symbol goes first (K.1) + let mut c1: Option = None; + for i in 0..257 { + if f[i] > 0 && c1.is_none_or(|c| f[i] <= f[c]) { + c1 = Some(i); + } + } + let Some(c1) = c1 else { break }; + let mut c2: Option = None; + for i in 0..257 { + if i != c1 && f[i] > 0 && c2.is_none_or(|c| f[i] <= f[c]) { + c2 = Some(i); + } + } + let Some(c2) = c2 else { break }; + f[c1] = f[c1].saturating_add(f[c2]); + f[c2] = 0; + // every symbol in both trees goes one level deeper; c2's chain is appended to c1's + let mut k = c1; + size[k] += 1; + while let Some(n) = next[k] { + k = n; + size[k] += 1; + } + next[k] = Some(c2); + let mut k = c2; + size[k] += 1; + while let Some(n) = next[k] { + k = n; + size[k] += 1; + } + } + // counts per code length; 257 symbols need at most 256 levels + let mut bits = [0usize; 257]; + for &s in &size { + if s > 0 { + bits[s.min(256)] += 1; + } + } + // limit to 16 bits (K.3): move pairs of the longest codes up, one level at a time (the + // deepest level of a full tree always holds an even number of codes) + for i in (17..257).rev() { + while bits[i] >= 2 { + let Some(j) = (1..i - 1).rev().find(|&j| bits[j] > 0) else { break }; + bits[i] -= 2; + bits[i - 1] += 1; + bits[j + 1] += 2; + bits[j] -= 1; + } + } + // drop the reserved symbol's code: it is one of the longest + if let Some(i) = (1..=16).rev().find(|&i| bits[i] > 0) { + bits[i] -= 1; + } + // symbols by code length, then by value (K.4); the reserved symbol is not listed + let mut vals = Vec::new(); + for len in 1..257 { + vals.extend((0..256).filter(|&s| size[s] == len).map(|s| s as u8)); + } + let mut out = [0u8; 16]; + for (o, &b) in out.iter_mut().zip(&bits[1..=16]) { + *o = b.min(255) as u8; + } + (out, vals) +} + fn scaled_quant(base: &[u16; 64], quality: u8) -> [u16; 64] { let q = quality.clamp(1, 100) as u32; let scale = if q < 50 { 5000 / q } else { 200 - 2 * q }; @@ -177,13 +267,15 @@ fn magnitude(v: i32) -> (u32, u32) { (size, bits & ((1u32 << size) - 1)) } -fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huff) { +/// The Huffman symbols of one quantized block in coding order: `sym(ac, symbol, bits, n)` for the +/// DC size category (`ac` false) and each AC run/size, ZRL and EOB (`ac` true), each followed by +/// its `n` extra bits `bits`. +#[inline(always)] +fn block_symbols(q: &[i16; 64], pred: &mut i32, mut sym: impl FnMut(bool, usize, u32, u32)) { let d = q[0] as i32 - *pred; *pred = q[0] as i32; let (s, b) = magnitude(d); - let (c, l) = dc.code[s as usize]; - w.put(c as u32, l as u32); - w.put(b, s); + sym(false, s as usize, b, s); let mut run = 0u32; for &zi in &ZIGZAG[1..] { let v = q[zi] as i32; @@ -192,22 +284,45 @@ fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huf continue; } while run >= 16 { - let (c, l) = ac.code[0xF0]; - w.put(c as u32, l as u32); + sym(true, 0xF0, 0, 0); run -= 16; } let (s, b) = magnitude(v); - let (c, l) = ac.code[((run << 4) | s) as usize]; - w.put(c as u32, l as u32); - w.put(b, s); + sym(true, ((run << 4) | s) as usize, b, s); run = 0; } if run > 0 { - let (c, l) = ac.code[0x00]; - w.put(c as u32, l as u32); + sym(true, 0x00, 0, 0); } } +/// One coded symbol of the entropy-coded data: bits 0–7 the symbol, 8–9 its table (0 DC luma, +/// 1 AC luma, 2 DC chroma, 3 AC chroma), 10–13 the number of extra bits, 14–29 those bits (a +/// baseline coefficient needs at most 11). +type Token = u32; + +/// Append one block's symbols to `out` and count them in `freq` (by table, as in [`Token`]). +fn tokenize_block(q: &[i16; 64], pred: &mut i32, chroma: bool, out: &mut Vec, freq: &mut [Freq; 4]) { + let base = if chroma { 2 } else { 0 }; + block_symbols(q, pred, |is_ac, s, bits, n| { + let table = base + is_ac as usize; + freq[table][s & 0xFF] += 1; + out.push((s as u32 & 0xFF) | (table as u32) << 8 | (n & 0xF) << 10 | (bits & 0xFFFF) << 14); + }); +} + +/// Code a band's symbols with `tables` (indexed as in [`Token`]). +fn write_tokens(tokens: &[Token], tables: &[Huff; 4]) -> Vec { + let mut w = Bits::new(); + for &t in tokens { + let (s, table, n, bits) = (t & 0xFF, (t >> 8) & 3, (t >> 10) & 0xF, t >> 14); + let (c, l) = tables[table as usize].code[s as usize]; + w.put(c as u32, l as u32); + w.put(bits, n); + } + w.finish() +} + /// Planar component samples (level-shifted to −128..127) with edge replication on read. struct Plane { w: usize, @@ -249,7 +364,8 @@ fn dht(out: &mut Vec, class_id: u8, bits: &[u8; 16], vals: &[u8]) { segment(out, 0xC4, &p); } -/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped). +/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped), with Huffman +/// tables made for the image. pub fn encode( data: &[u8], width: usize, @@ -258,6 +374,21 @@ pub fn encode( quality: u8, sub: ChromaSubsampling, app_segments: &[(u8, Vec)], +) -> Vec { + encode_with(data, width, height, channels, quality, sub, app_segments, true) +} + +/// [`encode`], with optimized Huffman tables or (`optimize` false) the Annex K example tables. +#[allow(clippy::too_many_arguments)] +fn encode_with( + data: &[u8], + width: usize, + height: usize, + channels: usize, + quality: u8, + sub: ChromaSubsampling, + app_segments: &[(u8, Vec)], + optimize: bool, ) -> Vec { let gray = channels == 1; let (hs, vs) = match (gray, sub) { @@ -331,42 +462,67 @@ pub fn encode( let ql = scaled_quant(&Q_LUMA, quality); let qc = scaled_quant(&Q_CHROMA, quality); let (rl, rc) = (recip_table(&ql), recip_table(&qc)); - let (dcl, acl) = (Huff::new(&DC_LUMA_BITS, &DC_VALS), Huff::new(&AC_LUMA_BITS, &AC_LUMA_VALS)); - let (dcc, acc) = (Huff::new(&DC_CHROMA_BITS, &DC_VALS), Huff::new(&AC_CHROMA_BITS, &AC_CHROMA_VALS)); - let (mcu_w, mcu_h) = (8 * hs, 8 * vs); let (mx, my) = (width.div_ceil(mcu_w), height.div_ceil(mcu_h)); // restart interval: whole MCU rows per band, ≤ 65535 MCUs, enough bands to keep all cores busy let rows_per_band = (my / 64).clamp(1, (65_535 / mx.max(1)).max(1)); let bands: Vec = (0..my.div_ceil(rows_per_band)).collect(); - let encode_band = |band: &usize| -> Vec { - let mut w = Bits::new(); - let mut pred = [0i32; 3]; + // first pass: each band's quantized blocks as symbols, with their counts per table (DC + // prediction restarts per band, as the restart markers say) + let tokenize_band = |band: &usize| -> (Vec, [Freq; 4]) { let (mut blk, mut q) = ([0f32; 64], [0i16; 64]); + let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]); + let mut pred = [0i32; 3]; for mcu_y in band * rows_per_band..((band + 1) * rows_per_band).min(my) { for mcu_x in 0..mx { for vy in 0..vs { for hx in 0..hs { planes[0].block(mcu_x * hs + hx, mcu_y * vs + vy, &mut blk); fdct_quant(&blk, &rl, &mut q); - encode_block(&mut w, &q, &mut pred[0], &dcl, &acl); + tokenize_block(&q, &mut pred[0], false, &mut tokens, &mut freq); } } if !gray { - for k in 1..3 { - planes[k].block(mcu_x, mcu_y, &mut blk); + for (plane, pred) in planes.iter().zip(pred.iter_mut()).take(3).skip(1) { + plane.block(mcu_x, mcu_y, &mut blk); fdct_quant(&blk, &rc, &mut q); - encode_block(&mut w, &q, &mut pred[k], &dcc, &acc); + tokenize_block(&q, pred, true, &mut tokens, &mut freq); } } } } - w.finish() + (tokens, freq) }; #[cfg(feature = "parallel")] - let coded: Vec> = bands.par_iter().map(encode_band).collect(); + let tokenized: Vec<(Vec, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect(); #[cfg(not(feature = "parallel"))] - let coded: Vec> = bands.iter().map(encode_band).collect(); + let tokenized: Vec<(Vec, [Freq; 4])> = bands.iter().map(tokenize_band).collect(); + drop(planes); + // the tables: DC luma, AC luma, DC chroma, AC chroma + let tables: [([u8; 16], Vec); 4] = if optimize { + let mut total = [[0u64; 256]; 4]; + for (_, f) in &tokenized { + for (t, f) in total.iter_mut().zip(f) { + for (t, f) in t.iter_mut().zip(f) { + *t = t.saturating_add(*f); + } + } + } + total.each_ref().map(optimal_table) + } else { + [ + (DC_LUMA_BITS, DC_VALS.to_vec()), + (AC_LUMA_BITS, AC_LUMA_VALS.to_vec()), + (DC_CHROMA_BITS, DC_VALS.to_vec()), + (AC_CHROMA_BITS, AC_CHROMA_VALS.to_vec()), + ] + }; + let huff = tables.each_ref().map(|(bits, vals)| Huff::new(bits, vals)); + // second pass: code the symbols + #[cfg(feature = "parallel")] + let coded: Vec> = tokenized.into_par_iter().map(|(t, _)| write_tokens(&t, &huff)).collect(); + #[cfg(not(feature = "parallel"))] + let coded: Vec> = tokenized.into_iter().map(|(t, _)| write_tokens(&t, &huff)).collect(); // headers let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::() + 4096); @@ -396,11 +552,12 @@ pub fn encode( sof.extend_from_slice(&[3, 1, ((hs as u8) << 4) | vs as u8, 0, 2, 0x11, 1, 3, 0x11, 1]); } segment(&mut out, 0xC0, &sof); - dht(&mut out, 0x00, &DC_LUMA_BITS, &DC_VALS); - dht(&mut out, 0x10, &AC_LUMA_BITS, &AC_LUMA_VALS); + let [(dlb, dlv), (alb, alv), (dcb, dcv), (acb, acv)] = &tables; + dht(&mut out, 0x00, dlb, dlv); + dht(&mut out, 0x10, alb, alv); if !gray { - dht(&mut out, 0x01, &DC_CHROMA_BITS, &DC_VALS); - dht(&mut out, 0x11, &AC_CHROMA_BITS, &AC_CHROMA_VALS); + dht(&mut out, 0x01, dcb, dcv); + dht(&mut out, 0x11, acb, acv); } let interval = (rows_per_band * mx) as u16; if coded.len() > 1 { @@ -495,6 +652,100 @@ mod tests { } } + /// The code lengths of `(bits, vals)`, checked: every listed symbol once, at most 16 bits, + /// and room left in the code space (the all-ones code is never assigned, Kraft sum < 1). + fn check_table(bits: &[u8; 16], vals: &[u8]) { + assert_eq!(bits.iter().map(|&b| b as usize).sum::(), vals.len()); + let mut seen = [false; 256]; + for &v in vals { + assert!(!seen[v as usize], "symbol {v} listed twice"); + seen[v as usize] = true; + } + let kraft: f64 = bits.iter().enumerate().map(|(i, &n)| n as f64 / 2f64.powi(i as i32 + 1)).sum(); + assert!(kraft < 1.0, "code space full: {kraft}"); + let h = Huff::new(bits, vals); + for &v in vals { + let (c, l) = h.code[v as usize]; + assert!((1..=16).contains(&l)); + assert_ne!(c as u32, (1u32 << l) - 1, "symbol {v} has the all-ones code"); + } + } + + #[test] + fn optimal_tables_are_valid_and_bounded() { + // one symbol, two, a typical spread and every symbol + let mut one = [0u64; 256]; + one[7] = 5; + let mut two = [0u64; 256]; + two[0] = 1; + two[0xF0] = 1_000_000; + let typical: Freq = std::array::from_fn(|s| if s % 3 == 0 { (s as u64 * 7919) % 1000 + 1 } else { 0 }); + let all: Freq = std::array::from_fn(|s| s as u64 + 1); + // Fibonacci counts make an unbounded Huffman tree ~40 levels deep: the 16-bit limit applies + let mut fib = [0u64; 256]; + let (mut a, mut b) = (1u64, 1u64); + for f in fib.iter_mut().take(40) { + *f = a; + (a, b) = (b, a + b); + } + for (name, f) in [("one", one), ("two", two), ("typical", typical), ("all", all), ("fib", fib), ("none", [0; 256])] { + let (bits, vals) = optimal_table(&f); + check_table(&bits, &vals); + for s in 0..256 { + assert_eq!(vals.contains(&(s as u8)), f[s] > 0 || (name == "none" && s == 0), "{name}: symbol {s}"); + } + } + // more frequent symbols never get longer codes + let (bits, vals) = optimal_table(&typical); + let h = Huff::new(&bits, &vals); + for &x in &vals { + for &y in &vals { + if typical[x as usize] > typical[y as usize] { + assert!(h.code[x as usize].1 <= h.code[y as usize].1, "{x} vs {y}"); + } + } + } + // the Annex K example tables pass the same checks + check_table(&AC_LUMA_BITS, &AC_LUMA_VALS); + check_table(&DC_CHROMA_BITS, &DC_VALS); + } + + /// Issue #1373: tables made for the image give a smaller file with exactly the same pixels as + /// the Annex K example tables (the quantized coefficients are the same), at every quality and + /// subsampling, and both decoders read it. + #[test] + fn optimized_tables_shrink_files_without_changing_pixels() { + let (w, h) = (517, 333); + // gradients plus deterministic noise, like a photo's grain + let mut seed = 0x2545_f491_u32; + let src: Vec = test_rgb(w, h) + .into_iter() + .map(|v| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + v.saturating_add((seed % 23) as u8) + }) + .collect(); + for (q, sub) in [(100, ChromaSubsampling::S444), (92, ChromaSubsampling::S444), (75, ChromaSubsampling::S420), (30, ChromaSubsampling::S420)] + { + let std = encode_with(&src, w, h, 3, q, sub, &[], false); + let opt = encode(&src, w, h, 3, q, sub, &[]); + assert!(opt.len() < std.len(), "q{q} {sub:?}: {} ≥ {}", opt.len(), std.len()); + let a = jpeg_decoder::Decoder::new(&std[..]).decode().expect("standard tables"); + let b = jpeg_decoder::Decoder::new(&opt[..]).decode().expect("optimized tables"); + assert_eq!(a, b, "q{q} {sub:?}: pixels differ"); + let z = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&opt[..])).decode().expect("zune-jpeg"); + assert_eq!(z.len(), b.len()); + } + // grayscale too + let gray: Vec = src.iter().step_by(3).copied().collect(); + let std = encode_with(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[], false); + let opt = encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[]); + assert!(opt.len() < std.len()); + assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap()); + } + #[test] fn grayscale_and_quality_monotone() { let (w, h) = (300, 200); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index e524cffe80..d14b1812f1 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -12,6 +12,7 @@ //! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and //! JPEG XL ([`encode_jxl`], feature `jxl`), embedding //! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export. +//! - [`video`] encodes AV1 video (native, feature `avif`) and writes it as MP4 or WebM. //! //! No decoder panics on malformed input (property-tested). #![forbid(unsafe_code)] @@ -31,11 +32,13 @@ mod jxl; mod jxl_encode; mod other; mod png_codec; +pub mod print; pub mod proof; mod psd; mod sniff; pub mod space; mod tiff_codec; +pub mod video; mod webp; pub use encode::{ diff --git a/crates/codecs/src/print.rs b/crates/codecs/src/print.rs new file mode 100644 index 0000000000..560e2710d0 --- /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/src/video.rs b/crates/codecs/src/video.rs new file mode 100644 index 0000000000..3991269d81 --- /dev/null +++ b/crates/codecs/src/video.rs @@ -0,0 +1,1158 @@ +//! Video: AV1 encoding of RGBA frames (`rav1e`, native targets with the `avif` feature) and two +//! small muxers of our own, written from the public specifications only: +//! +//! - **MP4** (ISO/IEC 14496-12 ISO base media file format + the AOM "AV1 Codec ISO Media File +//! Format Binding"): `ftyp`, one `mdat` (64-bit size, patched at the end) holding the samples, +//! then `moov` with one video track whose sample entry is `av01` + `av1C` + `colr` (`nclx`). +//! - **WebM** (Matroska / EBML as restricted by WebM, codec mapping `V_AV1`): EBML header, +//! Segment (size patched at the end) with SeekHead, Info, Tracks, Clusters of SimpleBlocks (the +//! last frame a BlockGroup with its duration) and Cues. +//! +//! Frames may last several frame periods: a picture held on screen is one sample with a long +//! duration (variable frame rate), so a still slide costs one encoded frame however long it +//! stays up. Durations are counted in frame periods of the nominal rate. +//! +//! Samples are AV1 temporal units without temporal delimiter OBUs (both bindings ask for that); +//! the configuration record carries the sequence header OBU. No audio track. + +use std::io::{Seek, SeekFrom, Write}; + +use crate::{Error, Result}; + +/// The container a video is written in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Container { + Mp4, + WebM, +} + +impl Container { + pub fn id(self) -> &'static str { + match self { + Container::Mp4 => "mp4", + Container::WebM => "webm", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "mp4" => Some(Container::Mp4), + "webm" => Some(Container::WebM), + _ => None, + } + } +} + +/// One encoded picture: an AV1 temporal unit (no temporal delimiter), whether it is a key frame +/// (a sync sample), and how many frame periods it stays on screen (≥ 1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sample { + pub data: Vec, + pub key: bool, + pub duration: u64, +} + +/// The longest video the muxers write, in frame periods (keeps every timestamp in range). +pub const MAX_PERIODS: u64 = 60 * 60 * 24 * 120; + +fn io(e: std::io::Error) -> Error { + Error::Encode(format!("video: {e}")) +} + +fn bad(msg: impl Into) -> Error { + Error::Encode(format!("video: {}", msg.into())) +} + +// ---------------------------------------------------------------- AV1 OBUs + +/// OBU types (AV1 specification §6.2.2). +pub const OBU_SEQUENCE_HEADER: u8 = 1; +pub const OBU_TEMPORAL_DELIMITER: u8 = 2; +pub const OBU_PADDING: u8 = 15; + +/// Read a LEB128 value at `pos` (at most 8 bytes); returns (value, bytes read). +fn leb128(data: &[u8], pos: usize) -> Result<(u64, usize)> { + let mut v = 0u64; + for i in 0..8 { + let b = *data.get(pos + i).ok_or_else(|| bad("truncated OBU size"))?; + v |= u64::from(b & 0x7f) << (7 * i); + if b & 0x80 == 0 { + return Ok((v, i + 1)); + } + } + Err(bad("OBU size longer than 8 bytes")) +} + +/// The OBUs of a temporal unit: (type, the whole OBU's byte range). Every OBU but the last must +/// carry its size (as the low-overhead bitstream format requires). +pub fn obus(data: &[u8]) -> Result)>> { + let mut out = Vec::new(); + let mut pos = 0usize; + while let Some(&header) = data.get(pos) { + if header & 0x80 != 0 { + return Err(bad("OBU forbidden bit set")); + } + let kind = (header >> 3) & 0x0f; + let ext = header & 0x04 != 0; + let has_size = header & 0x02 != 0; + let mut p = pos + 1 + usize::from(ext); + let end = if has_size { + let (size, n) = leb128(data, p)?; + p += n; + let size = usize::try_from(size).map_err(|_| bad("OBU too large"))?; + p.checked_add(size).filter(|e| *e <= data.len()).ok_or_else(|| bad("OBU runs past the end of the packet"))? + } else { + data.len() + }; + if p > end { + return Err(bad("truncated OBU header")); + } + out.push((kind, pos..end)); + pos = end; + } + Ok(out) +} + +/// A temporal unit as a sample: temporal delimiters and padding dropped. Also returns its +/// sequence header OBU, if it has one. +pub fn to_sample_data(tu: &[u8]) -> Result<(Vec, Option>)> { + let mut data = Vec::with_capacity(tu.len()); + let mut seq = None; + for (kind, r) in obus(tu)? { + let Some(bytes) = tu.get(r) else { continue }; + match kind { + OBU_TEMPORAL_DELIMITER | OBU_PADDING => {} + _ => { + if kind == OBU_SEQUENCE_HEADER && seq.is_none() { + seq = Some(bytes.to_vec()); + } + data.extend_from_slice(bytes); + } + } + } + Ok((data, seq)) +} + +/// The `av1C` configuration record (AV1-ISOBMFF §2.3) for 8-bit 4:2:0 main profile, followed by +/// `seq_header` (the sequence header OBU, may be empty). Matroska's `CodecPrivate` is the same. +pub fn av1_config(seq_header: &[u8]) -> Vec { + // marker 1, version 1 | profile 0, level 31 (unconstrained) | tier 0, 8-bit, not mono, + // subsampling x and y, chroma position 0 | no initial presentation delay + let mut v = vec![0x81, 31, 0b0000_1100, 0]; + v.extend_from_slice(seq_header); + v +} + +// ---------------------------------------------------------------- MP4 + +fn mp4_box(kind: &[u8; 4], body: &[u8]) -> Result> { + let size = u32::try_from(body.len().checked_add(8).ok_or_else(|| bad("box too large"))?).map_err(|_| bad("box too large"))?; + let mut v = Vec::with_capacity(body.len() + 8); + v.extend_from_slice(&size.to_be_bytes()); + v.extend_from_slice(kind); + v.extend_from_slice(body); + Ok(v) +} + +fn full_box(kind: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Result> { + let mut b = Vec::with_capacity(body.len() + 4); + b.push(version); + b.extend_from_slice(&flags.to_be_bytes()[1..]); + b.extend_from_slice(body); + mp4_box(kind, &b) +} + +/// The unity transformation matrix of `mvhd` / `tkhd`. +fn matrix() -> Vec { + let mut m = Vec::with_capacity(36); + for v in [0x0001_0000u32, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000] { + m.extend_from_slice(&v.to_be_bytes()); + } + m +} + +/// An MP4 writer: [`Mp4Writer::add`] streams each sample into `mdat`; [`Mp4Writer::finish`] +/// patches its size and appends `moov`. +pub struct Mp4Writer { + w: W, + width: u16, + height: u16, + fps: u32, + /// Where `mdat`'s 64-bit size is, and where its data starts. + mdat_size_at: u64, + data_start: u64, + written: u64, + sizes: Vec, + durations: Vec, + keys: Vec, + config: Option>, +} + +/// Track ticks per frame period (ffmpeg's habit: 512 per frame). +const TICKS: u64 = 512; + +impl Mp4Writer { + pub fn new(mut w: W, width: u32, height: u32, fps: u32) -> Result { + let (width, height) = (u16::try_from(width).map_err(|_| bad("too wide"))?, u16::try_from(height).map_err(|_| bad("too tall"))?); + if width == 0 || height == 0 || !(1..=240).contains(&fps) { + return Err(bad("bad size or frame rate")); + } + let mut ftyp = Vec::new(); + ftyp.extend_from_slice(b"isom"); + ftyp.extend_from_slice(&0x200u32.to_be_bytes()); + for brand in [b"isom", b"iso6", b"av01", b"mp41"] { + ftyp.extend_from_slice(brand); + } + w.write_all(&mp4_box(b"ftyp", &ftyp)?).map_err(io)?; + let start = w.stream_position().map_err(io)?; + // size 1 = a 64-bit size follows (patched in finish) + w.write_all(&1u32.to_be_bytes()).map_err(io)?; + w.write_all(b"mdat").map_err(io)?; + w.write_all(&0u64.to_be_bytes()).map_err(io)?; + Ok(Mp4Writer { + w, + width, + height, + fps, + mdat_size_at: start + 8, + data_start: start + 16, + written: 0, + sizes: Vec::new(), + durations: Vec::new(), + keys: Vec::new(), + config: None, + }) + } + + /// The `av1C` record ([`av1_config`]); needed before [`Self::finish`]. + pub fn set_config(&mut self, config: Vec) { + self.config = Some(config); + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + let size = u32::try_from(s.data.len()).map_err(|_| bad("sample too large"))?; + if s.duration == 0 { + return Err(bad("a sample lasts at least one frame")); + } + if self.sizes.is_empty() && !s.key { + return Err(bad("the first sample must be a key frame")); + } + self.w.write_all(&s.data).map_err(io)?; + self.written = self.written.saturating_add(u64::from(size)); + self.sizes.push(size); + self.durations.push(s.duration); + if s.key { + let n = u32::try_from(self.sizes.len()).map_err(|_| bad("too many samples"))?; + self.keys.push(n); + } + Ok(()) + } + + /// Frame periods written so far. + pub fn periods(&self) -> u64 { + self.durations.iter().sum() + } + + pub fn finish(mut self) -> Result { + if self.sizes.is_empty() { + return Err(bad("no frames")); + } + let config = self.config.take().ok_or_else(|| bad("no AV1 configuration"))?; + let periods = self.periods(); + if periods > MAX_PERIODS { + return Err(bad("video too long")); + } + let end = self.w.stream_position().map_err(io)?; + self.w.seek(SeekFrom::Start(self.mdat_size_at)).map_err(io)?; + self.w.write_all(&(end - (self.mdat_size_at - 8)).to_be_bytes()).map_err(io)?; + self.w.seek(SeekFrom::Start(end)).map_err(io)?; + let moov = self.moov(&config, periods)?; + self.w.write_all(&moov).map_err(io)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } + + fn moov(&self, config: &[u8], periods: u64) -> Result> { + let n = u32::try_from(self.sizes.len()).map_err(|_| bad("too many samples"))?; + let timescale = u32::try_from(u64::from(self.fps) * TICKS).map_err(|_| bad("bad frame rate"))?; + let ticks = periods.checked_mul(TICKS).ok_or_else(|| bad("video too long"))?; + // the movie's own timescale: milliseconds + let movie_ms = periods.saturating_mul(1000) / u64::from(self.fps); + let u32_of = |v: u64| u32::try_from(v).map_err(|_| bad("video too long")); + let (track_ticks, movie_ms) = (u32_of(ticks)?, u32_of(movie_ms)?); + + let mut mvhd = Vec::new(); + mvhd.extend_from_slice(&[0; 8]); // creation, modification + mvhd.extend_from_slice(&1000u32.to_be_bytes()); + mvhd.extend_from_slice(&movie_ms.to_be_bytes()); + mvhd.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0 + mvhd.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0 + mvhd.extend_from_slice(&[0; 10]); + mvhd.extend_from_slice(&matrix()); + mvhd.extend_from_slice(&[0; 24]); // pre_defined + mvhd.extend_from_slice(&2u32.to_be_bytes()); // next track id + + let mut tkhd = Vec::new(); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&1u32.to_be_bytes()); // track id + tkhd.extend_from_slice(&[0; 4]); + tkhd.extend_from_slice(&movie_ms.to_be_bytes()); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&[0; 2]); // layer + tkhd.extend_from_slice(&[0; 2]); // alternate group + tkhd.extend_from_slice(&[0; 2]); // volume (video: 0) + tkhd.extend_from_slice(&[0; 2]); + tkhd.extend_from_slice(&matrix()); + tkhd.extend_from_slice(&(u32::from(self.width) << 16).to_be_bytes()); + tkhd.extend_from_slice(&(u32::from(self.height) << 16).to_be_bytes()); + + let mut mdhd = Vec::new(); + mdhd.extend_from_slice(&[0; 8]); + mdhd.extend_from_slice(×cale.to_be_bytes()); + mdhd.extend_from_slice(&track_ticks.to_be_bytes()); + mdhd.extend_from_slice(&0x55c4u16.to_be_bytes()); // language "und" + mdhd.extend_from_slice(&[0; 2]); + + let mut hdlr = Vec::new(); + hdlr.extend_from_slice(&[0; 4]); + hdlr.extend_from_slice(b"vide"); + hdlr.extend_from_slice(&[0; 12]); + hdlr.extend_from_slice(b"LightCraft video\0"); + + let mut vmhd = Vec::new(); + vmhd.extend_from_slice(&[0; 8]); // graphics mode, opcolor + let url = full_box(b"url ", 0, 1, &[])?; + let mut dref = 1u32.to_be_bytes().to_vec(); + dref.extend_from_slice(&url); + let dinf = mp4_box(b"dinf", &full_box(b"dref", 0, 0, &dref)?)?; + + // av01 visual sample entry + let mut av01 = Vec::new(); + av01.extend_from_slice(&[0; 6]); + av01.extend_from_slice(&1u16.to_be_bytes()); // data reference index + av01.extend_from_slice(&[0; 16]); // pre_defined, reserved + av01.extend_from_slice(&self.width.to_be_bytes()); + av01.extend_from_slice(&self.height.to_be_bytes()); + av01.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // 72 dpi + av01.extend_from_slice(&0x0048_0000u32.to_be_bytes()); + av01.extend_from_slice(&[0; 4]); + av01.extend_from_slice(&1u16.to_be_bytes()); // frame count + let mut name = [0u8; 32]; + let label = b"AOM Coding"; + name[0] = label.len() as u8; + name[1..=label.len()].copy_from_slice(label); + av01.extend_from_slice(&name); + av01.extend_from_slice(&0x0018u16.to_be_bytes()); // depth + av01.extend_from_slice(&(-1i16).to_be_bytes()); + av01.extend_from_slice(&mp4_box(b"av1C", config)?); + // BT.709 primaries, transfer and matrix, limited range + let mut colr = b"nclx".to_vec(); + colr.extend_from_slice(&[0, 1, 0, 1, 0, 1, 0]); + av01.extend_from_slice(&mp4_box(b"colr", &colr)?); + let mut pasp = 1u32.to_be_bytes().to_vec(); + pasp.extend_from_slice(&1u32.to_be_bytes()); + av01.extend_from_slice(&mp4_box(b"pasp", &pasp)?); + let mut stsd = 1u32.to_be_bytes().to_vec(); + stsd.extend_from_slice(&mp4_box(b"av01", &av01)?); + + // decoding times: runs of equal durations + let mut runs: Vec<(u32, u64)> = Vec::new(); + for d in &self.durations { + match runs.last_mut() { + Some((count, delta)) if *delta == *d => *count += 1, + _ => runs.push((1, *d)), + } + } + let mut stts = u32::try_from(runs.len()).map_err(|_| bad("too many samples"))?.to_be_bytes().to_vec(); + for (count, delta) in runs { + stts.extend_from_slice(&count.to_be_bytes()); + stts.extend_from_slice(&u32_of(delta.saturating_mul(TICKS))?.to_be_bytes()); + } + let mut stsc = 1u32.to_be_bytes().to_vec(); + for v in [1u32, n, 1] { + stsc.extend_from_slice(&v.to_be_bytes()); + } + let mut stsz = 0u32.to_be_bytes().to_vec(); + stsz.extend_from_slice(&n.to_be_bytes()); + for s in &self.sizes { + stsz.extend_from_slice(&s.to_be_bytes()); + } + // one chunk holding every sample, at the start of mdat's data + let chunk_offset = match u32::try_from(self.data_start) { + Ok(o) => full_box(b"stco", 0, 0, &[1u32.to_be_bytes(), o.to_be_bytes()].concat())?, + Err(_) => { + let mut b = 1u32.to_be_bytes().to_vec(); + b.extend_from_slice(&self.data_start.to_be_bytes()); + full_box(b"co64", 0, 0, &b)? + } + }; + let mut stbl = Vec::new(); + stbl.extend_from_slice(&full_box(b"stsd", 0, 0, &stsd)?); + stbl.extend_from_slice(&full_box(b"stts", 0, 0, &stts)?); + if self.keys.len() < self.sizes.len() { + let mut stss = u32::try_from(self.keys.len()).map_err(|_| bad("too many samples"))?.to_be_bytes().to_vec(); + for k in &self.keys { + stss.extend_from_slice(&k.to_be_bytes()); + } + stbl.extend_from_slice(&full_box(b"stss", 0, 0, &stss)?); + } + stbl.extend_from_slice(&full_box(b"stsc", 0, 0, &stsc)?); + stbl.extend_from_slice(&full_box(b"stsz", 0, 0, &stsz)?); + stbl.extend_from_slice(&chunk_offset); + + let mut minf = full_box(b"vmhd", 0, 1, &vmhd)?; + minf.extend_from_slice(&dinf); + minf.extend_from_slice(&mp4_box(b"stbl", &stbl)?); + let mut mdia = full_box(b"mdhd", 0, 0, &mdhd)?; + mdia.extend_from_slice(&full_box(b"hdlr", 0, 0, &hdlr)?); + mdia.extend_from_slice(&mp4_box(b"minf", &minf)?); + let mut trak = full_box(b"tkhd", 0, 3, &tkhd)?; + trak.extend_from_slice(&mp4_box(b"mdia", &mdia)?); + let mut moov = full_box(b"mvhd", 0, 0, &mvhd)?; + moov.extend_from_slice(&mp4_box(b"trak", &trak)?); + mp4_box(b"moov", &moov) + } +} + +// ---------------------------------------------------------------- WebM + +/// An EBML element id with its length marker, as written (e.g. `0x1A45DFA3`). +fn ebml_id(id: u32) -> Vec { + let b = id.to_be_bytes(); + let skip = b.iter().take_while(|x| **x == 0).count().min(3); + b[skip..].to_vec() +} + +/// An EBML size, in the fewest bytes (all-ones values are reserved for "unknown"). +fn ebml_size(n: u64) -> Vec { + for len in 1..=8u32 { + if n < (1u64 << (7 * len)) - 1 { + let mut v = Vec::with_capacity(len as usize); + let marked = n | (1u64 << (7 * len)); + let bytes = marked.to_be_bytes(); + v.extend_from_slice(&bytes[(8 - len as usize)..]); + return v; + } + } + vec![0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe] +} + +/// An 8-byte EBML size (patchable in place). +fn ebml_size8(n: u64) -> [u8; 8] { + let mut b = (n & 0x00ff_ffff_ffff_ffff).to_be_bytes(); + b[0] = 0x01; + b +} + +fn el(id: u32, body: &[u8]) -> Vec { + let mut v = ebml_id(id); + v.extend_from_slice(&ebml_size(body.len() as u64)); + v.extend_from_slice(body); + v +} + +fn el_uint(id: u32, n: u64) -> Vec { + let b = n.to_be_bytes(); + let skip = b.iter().take_while(|x| **x == 0).count().min(7); + el(id, &b[skip..]) +} + +fn el_float(id: u32, f: f64) -> Vec { + el(id, &f.to_be_bytes()) +} + +const EBML: u32 = 0x1A45_DFA3; +const SEGMENT: u32 = 0x1853_8067; +const SEEK_HEAD: u32 = 0x114D_9B74; +const SEEK: u32 = 0x4DBB; +const SEEK_ID: u32 = 0x53AB; +const SEEK_POSITION: u32 = 0x53AC; +const VOID: u32 = 0xEC; +const INFO: u32 = 0x1549_A966; +const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; +const DURATION: u32 = 0x4489; +const TRACKS: u32 = 0x1654_AE6B; +const TRACK_ENTRY: u32 = 0xAE; +const CLUSTER: u32 = 0x1F43_B675; +const TIMESTAMP: u32 = 0xE7; +const SIMPLE_BLOCK: u32 = 0xA3; +const BLOCK_GROUP: u32 = 0xA0; +const BLOCK: u32 = 0xA1; +const BLOCK_DURATION: u32 = 0x9B; +const REFERENCE_BLOCK: u32 = 0xFB; +const CUES: u32 = 0x1C53_BB6B; + +/// Room kept after the Segment header for the SeekHead written at the end. +const SEEK_ROOM: usize = 96; + +/// A WebM writer (see the module docs). Timestamps are in milliseconds. +pub struct WebmWriter { + w: W, + width: u32, + height: u32, + fps: u32, + segment_size_at: u64, + segment_start: u64, + duration_at: u64, + info_at: u64, + tracks_at: Option, + config: Option>, + /// Frame periods before the pending sample. + periods: u64, + /// The sample not written yet (the last one becomes a BlockGroup with its duration). + pending: Option<(Sample, u64)>, + cluster: Vec, + cluster_ts: u64, + last_block_ts: u64, + cues: Vec<(u64, u64)>, +} + +impl WebmWriter { + pub fn new(mut w: W, width: u32, height: u32, fps: u32) -> Result { + if width == 0 || height == 0 || width > 65_535 || height > 65_535 || !(1..=240).contains(&fps) { + return Err(bad("bad size or frame rate")); + } + let mut header = Vec::new(); + header.extend_from_slice(&el_uint(0x4286, 1)); // EBMLVersion + header.extend_from_slice(&el_uint(0x42F7, 1)); // EBMLReadVersion + header.extend_from_slice(&el_uint(0x42F2, 4)); // EBMLMaxIDLength + header.extend_from_slice(&el_uint(0x42F3, 8)); // EBMLMaxSizeLength + header.extend_from_slice(&el(0x4282, b"webm")); // DocType + header.extend_from_slice(&el_uint(0x4287, 4)); // DocTypeVersion + header.extend_from_slice(&el_uint(0x4285, 2)); // DocTypeReadVersion + w.write_all(&el(EBML, &header)).map_err(io)?; + w.write_all(&ebml_id(SEGMENT)).map_err(io)?; + let segment_size_at = w.stream_position().map_err(io)?; + w.write_all(&ebml_size8(0)).map_err(io)?; + let segment_start = w.stream_position().map_err(io)?; + // a Void the SeekHead replaces at the end + w.write_all(&el(VOID, &[0; SEEK_ROOM - 2])).map_err(io)?; + let info_at = w.stream_position().map_err(io)? - segment_start; + let mut info = el_uint(TIMESTAMP_SCALE, 1_000_000); + let duration_offset = info.len() + 3; // after Duration's id (2 bytes) and size (1 byte) + info.extend_from_slice(&el_float(DURATION, 0.0)); + info.extend_from_slice(&el(0x4D80, b"LightCraft")); // MuxingApp + info.extend_from_slice(&el(0x5741, b"LightCraft")); // WritingApp + let info_el = el(INFO, &info); + let duration_at = segment_start + info_at + (info_el.len() - info.len()) as u64 + duration_offset as u64; + w.write_all(&info_el).map_err(io)?; + Ok(WebmWriter { + w, + width, + height, + fps, + segment_size_at, + segment_start, + duration_at, + info_at, + tracks_at: None, + config: None, + periods: 0, + pending: None, + cluster: Vec::new(), + cluster_ts: 0, + last_block_ts: 0, + cues: Vec::new(), + }) + } + + /// The `CodecPrivate` (the `av1C` record, [`av1_config`]); needed before the first sample. + pub fn set_config(&mut self, config: Vec) { + self.config = Some(config); + } + + fn ms(&self, periods: u64) -> u64 { + periods.saturating_mul(1000) / u64::from(self.fps) + } + + fn write_tracks(&mut self) -> Result<()> { + let config = self.config.clone().ok_or_else(|| bad("no AV1 configuration"))?; + let mut video = el_uint(0xB0, u64::from(self.width)); // PixelWidth + video.extend_from_slice(&el_uint(0xBA, u64::from(self.height))); // PixelHeight + let mut entry = el_uint(0xD7, 1); // TrackNumber + entry.extend_from_slice(&el_uint(0x73C5, 1)); // TrackUID + entry.extend_from_slice(&el_uint(0x83, 1)); // TrackType video + entry.extend_from_slice(&el_uint(0x9C, 0)); // FlagLacing + entry.extend_from_slice(&el(0x86, b"V_AV1")); // CodecID + entry.extend_from_slice(&el(0x63A2, &config)); // CodecPrivate + entry.extend_from_slice(&el_uint(0x23E383, 1_000_000_000 / u64::from(self.fps))); // DefaultDuration (ns) + entry.extend_from_slice(&el(0xE0, &video)); + let at = self.w.stream_position().map_err(io)? - self.segment_start; + self.w.write_all(&el(TRACKS, &el(TRACK_ENTRY, &entry))).map_err(io)?; + self.tracks_at = Some(at); + Ok(()) + } + + fn flush_cluster(&mut self) -> Result<()> { + if self.cluster.is_empty() { + return Ok(()); + } + let mut body = el_uint(TIMESTAMP, self.cluster_ts); + body.extend_from_slice(&self.cluster); + let at = self.w.stream_position().map_err(io)? - self.segment_start; + self.w.write_all(&el(CLUSTER, &body)).map_err(io)?; + self.cues.push((self.cluster_ts, at)); + self.cluster.clear(); + Ok(()) + } + + /// The block header: track 1, timestamp relative to the cluster, flags. + fn block(&self, data: &[u8], ts: u64, flags: u8) -> Result> { + let rel = i16::try_from(ts.saturating_sub(self.cluster_ts)).map_err(|_| bad("block timestamp out of range"))?; + let mut b = Vec::with_capacity(data.len() + 4); + b.push(0x81); + b.extend_from_slice(&rel.to_be_bytes()); + b.push(flags); + b.extend_from_slice(data); + Ok(b) + } + + /// Start a new cluster at a key frame or before the relative timestamp could overflow. + fn place(&mut self, key: bool, ts: u64) -> Result<()> { + if !self.cluster.is_empty() && (key || ts.saturating_sub(self.cluster_ts) > 30_000) { + self.flush_cluster()?; + } + if self.cluster.is_empty() { + self.cluster_ts = ts; + } + Ok(()) + } + + fn write_simple(&mut self, s: &Sample, ts: u64) -> Result<()> { + self.place(s.key, ts)?; + let b = self.block(&s.data, ts, if s.key { 0x80 } else { 0 })?; + self.cluster.extend_from_slice(&el(SIMPLE_BLOCK, &b)); + self.last_block_ts = ts; + Ok(()) + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + if s.duration == 0 { + return Err(bad("a sample lasts at least one frame")); + } + if self.tracks_at.is_none() { + if !s.key { + return Err(bad("the first sample must be a key frame")); + } + self.write_tracks()?; + } + if let Some((p, start)) = self.pending.take() { + let ts = self.ms(start); + self.write_simple(&p, ts)?; + } + let start = self.periods; + self.periods = self.periods.saturating_add(s.duration); + if self.periods > MAX_PERIODS { + return Err(bad("video too long")); + } + self.pending = Some((s.clone(), start)); + Ok(()) + } + + pub fn periods(&self) -> u64 { + self.periods + } + + pub fn finish(mut self) -> Result { + let (last, start) = self.pending.take().ok_or_else(|| bad("no frames"))?; + let ts = self.ms(start); + self.place(last.key, ts)?; + let mut group = el(BLOCK, &self.block(&last.data, ts, 0)?); + group.extend_from_slice(&el_uint(BLOCK_DURATION, self.ms(self.periods).saturating_sub(ts).max(1))); + if !last.key { + // the previous block, as a (negative) offset from this one + let back = i64::try_from(ts.saturating_sub(self.last_block_ts)).unwrap_or(i64::MAX).max(1); + el_int_into(&mut group, REFERENCE_BLOCK, -back); + } + self.cluster.extend_from_slice(&el(BLOCK_GROUP, &group)); + self.flush_cluster()?; + // cues: one per cluster (each starts with a key frame or carries on from one) + let cues_at = self.w.stream_position().map_err(io)? - self.segment_start; + let mut cues = Vec::new(); + for (t, pos) in &self.cues { + let mut positions = el_uint(0xF7, 1); // CueTrack + positions.extend_from_slice(&el_uint(0xF1, *pos)); // CueClusterPosition + let mut point = el_uint(0xB3, *t); // CueTime + point.extend_from_slice(&el(0xB7, &positions)); // CueTrackPositions + cues.extend_from_slice(&el(0xBB, &point)); // CuePoint + } + self.w.write_all(&el(CUES, &cues)).map_err(io)?; + let end = self.w.stream_position().map_err(io)?; + // SeekHead in the room kept for it, the rest stays a Void + let tracks_at = self.tracks_at.ok_or_else(|| bad("no tracks"))?; + let mut seeks = Vec::new(); + for (id, at) in [(INFO, self.info_at), (TRACKS, tracks_at), (CUES, cues_at)] { + let mut seek = el(SEEK_ID, &ebml_id(id)); + seek.extend_from_slice(&el(SEEK_POSITION, &at.to_be_bytes())); + seeks.extend_from_slice(&el(SEEK, &seek)); + } + let head = el(SEEK_HEAD, &seeks); + let rest = SEEK_ROOM.checked_sub(head.len()).filter(|r| *r >= 2).ok_or_else(|| bad("seek head too large"))?; + let mut room = head; + room.extend_from_slice(&el(VOID, &vec![0; rest - 2])); + self.w.seek(SeekFrom::Start(self.segment_start)).map_err(io)?; + self.w.write_all(&room).map_err(io)?; + self.w.seek(SeekFrom::Start(self.duration_at)).map_err(io)?; + self.w.write_all(&(self.ms(self.periods) as f64).to_be_bytes()).map_err(io)?; + self.w.seek(SeekFrom::Start(self.segment_size_at)).map_err(io)?; + self.w.write_all(&ebml_size8(end - self.segment_start)).map_err(io)?; + self.w.seek(SeekFrom::Start(end)).map_err(io)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } +} + +fn el_int_into(out: &mut Vec, id: u32, v: i64) { + let b = v.to_be_bytes(); + // the fewest bytes that keep the sign + let mut skip = 0; + while skip < 7 && ((b[skip] == 0 && b[skip + 1] & 0x80 == 0) || (b[skip] == 0xff && b[skip + 1] & 0x80 != 0)) { + skip += 1; + } + out.extend_from_slice(&el(id, &b[skip..])); +} + +// ---------------------------------------------------------------- either container + +/// An MP4 or WebM writer. +pub enum VideoWriter { + Mp4(Mp4Writer), + WebM(WebmWriter), +} + +impl VideoWriter { + pub fn new(w: W, container: Container, width: u32, height: u32, fps: u32) -> Result { + Ok(match container { + Container::Mp4 => VideoWriter::Mp4(Mp4Writer::new(w, width, height, fps)?), + Container::WebM => VideoWriter::WebM(WebmWriter::new(w, width, height, fps)?), + }) + } + + pub fn set_config(&mut self, config: Vec) { + match self { + VideoWriter::Mp4(m) => m.set_config(config), + VideoWriter::WebM(m) => m.set_config(config), + } + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + match self { + VideoWriter::Mp4(m) => m.add(s), + VideoWriter::WebM(m) => m.add(s), + } + } + + pub fn periods(&self) -> u64 { + match self { + VideoWriter::Mp4(m) => m.periods(), + VideoWriter::WebM(m) => m.periods(), + } + } + + pub fn finish(self) -> Result { + match self { + VideoWriter::Mp4(m) => m.finish(), + VideoWriter::WebM(m) => m.finish(), + } + } +} + +// ---------------------------------------------------------------- AV1 encoder + +/// How to encode: frame size (even numbers; odd ones are rounded down), nominal frame rate, +/// quality 1–100 and rav1e speed 1 (slow) – 10 (fast). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VideoConfig { + pub width: u32, + pub height: u32, + pub fps: u32, + pub quality: u8, + pub speed: u8, +} + +/// Is AV1 video encoding in this build (native, `avif` feature)? +pub const AV1_AVAILABLE: bool = cfg!(all(feature = "avif", not(target_arch = "wasm32"))); + +/// BT.709 limited-range 4:2:0 planes of an sRGB RGBA image (alpha ignored), `w × h` (even). +pub fn yuv420(rgba: &[[u8; 4]], w: usize, h: usize) -> Result<(Vec, Vec, Vec)> { + if w == 0 || h == 0 || !w.is_multiple_of(2) || !h.is_multiple_of(2) || w.checked_mul(h).is_none_or(|n| rgba.len() < n) { + return Err(bad("frame size")); + } + const KR: f32 = 0.2126; + const KB: f32 = 0.0722; + const KG: f32 = 1.0 - KR - KB; + let mut y = vec![0u8; w * h]; + let (cw, ch) = (w / 2, h / 2); + let mut u = vec![0u8; cw * ch]; + let mut v = vec![0u8; cw * ch]; + let luma = |p: [u8; 4]| KR * f32::from(p[0]) + KG * f32::from(p[1]) + KB * f32::from(p[2]); + for (i, out) in y.iter_mut().enumerate() { + let p = rgba.get(i).copied().unwrap_or([0; 4]); + *out = (16.0 + luma(p) * (219.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + for cy in 0..ch { + for cx in 0..cw { + let mut acc = [0f32; 3]; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let p = rgba.get((cy * 2 + dy) * w + cx * 2 + dx).copied().unwrap_or([0; 4]); + for (a, c) in acc.iter_mut().zip(p) { + *a += f32::from(c) / 4.0; + } + } + let l = KR * acc[0] + KG * acc[1] + KB * acc[2]; + let cb = (acc[2] - l) / (2.0 * (1.0 - KB)); + let cr = (acc[0] - l) / (2.0 * (1.0 - KR)); + let i = cy * cw + cx; + if let Some(o) = u.get_mut(i) { + *o = (128.0 + cb * (224.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + if let Some(o) = v.get_mut(i) { + *o = (128.0 + cr * (224.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + } + } + Ok((y, u, v)) +} + +/// The quantizer for `quality` (ravif's curve, so qualities match the AVIF export). +pub fn quantizer(quality: u8) -> usize { + let q = f32::from(quality.clamp(1, 100)) / 100.0; + let x = if q >= 0.82 { + (1.0 - q) * 2.6 + } else if q > 0.25 { + 1.0 - 0.125 - q * 0.5 + } else { + 1.0 - q + }; + (x * 255.0).round().clamp(0.0, 255.0) as usize +} + +/// An AV1 encoder fed RGBA frames, each with how many frame periods it stays up; it hands back +/// the samples as they come out (in order: no frame reordering). +pub struct Av1Encoder { + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + ctx: rav1e::Context, + /// Each frame sent so far: its duration (by input frame number). + #[cfg_attr(not(all(feature = "avif", not(target_arch = "wasm32"))), allow(dead_code))] + durations: Vec, + width: usize, + height: usize, + seq_header: Option>, +} + +impl Av1Encoder { + pub fn new(c: &VideoConfig) -> Result { + let (w, h) = ((c.width & !1) as usize, (c.height & !1) as usize); + if w < 16 || h < 16 || w > 8192 || h > 8192 || !(1..=240).contains(&c.fps) { + return Err(bad("unsupported video size or frame rate")); + } + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + use rav1e::prelude::*; + let mut ec = EncoderConfig::with_speed_preset(c.speed.clamp(1, 10)); + ec.width = w; + ec.height = h; + ec.time_base = Rational::new(1, u64::from(c.fps)); + ec.bit_depth = 8; + ec.chroma_sampling = ChromaSampling::Cs420; + ec.pixel_range = PixelRange::Limited; + ec.color_description = Some(ColorDescription { + color_primaries: ColorPrimaries::BT709, + transfer_characteristics: TransferCharacteristics::BT709, + matrix_coefficients: MatrixCoefficients::BT709, + }); + // in order, one shown frame per packet: samples map 1:1 to the frames sent + ec.low_latency = true; + let q = quantizer(c.quality); + ec.quantizer = q; + ec.min_quantizer = q.min(255) as u8; + ec.min_key_frame_interval = 1; + ec.max_key_frame_interval = 120; + // tiles are what rav1e encodes in parallel (16 measured best for 1080p on 32 threads) + ec.tiles = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(2, 16); + let ctx: Context = Config::new().with_encoder_config(ec).new_context().map_err(|e| bad(format!("AV1: {e}")))?; + Ok(Av1Encoder { ctx, durations: Vec::new(), width: w, height: h, seq_header: None }) + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (w, h); + Err(bad("AV1 video encoding is not available in this build")) + } + } + + /// The frame size actually encoded (even numbers). + pub fn size(&self) -> (usize, usize) { + (self.width, self.height) + } + + /// The configuration record for the container ([`av1_config`]), once the first sample (with + /// the sequence header) has come out. + pub fn config_record(&self) -> Option> { + self.seq_header.as_ref().map(|s| av1_config(s)) + } + + /// Encode `rgba` (`width × height` of [`Self::size`]) shown for `periods` frame periods; + /// `key` forces a key frame (a picture that doesn't lean on earlier ones: a held slide is + /// then clean of the slide before it, and seeking lands on it). + pub fn push(&mut self, rgba: &[[u8; 4]], periods: u64, key: bool) -> Result> { + let (y, u, v) = yuv420(rgba, self.width, self.height)?; + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + let mut frame = self.ctx.new_frame(); + for (plane, (data, w)) in frame.planes.iter_mut().zip([(&y, self.width), (&u, self.width / 2), (&v, self.width / 2)]) { + plane.copy_from_raw_u8(data, w, 1); + } + let params = rav1e::prelude::FrameParameters { + frame_type_override: if key { rav1e::prelude::FrameTypeOverride::Key } else { rav1e::prelude::FrameTypeOverride::No }, + ..Default::default() + }; + self.ctx.send_frame((frame, params)).map_err(|e| bad(format!("AV1: {e}")))?; + self.durations.push(periods.max(1)); + self.drain() + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (y, u, v, periods, key); + Err(bad("AV1 video encoding is not available in this build")) + } + } + + /// Flush the encoder: the samples still inside. + pub fn finish(&mut self) -> Result> { + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + self.ctx.flush(); + self.drain() + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + Err(bad("AV1 video encoding is not available in this build")) + } + + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + fn drain(&mut self) -> Result> { + use rav1e::prelude::*; + let mut out = Vec::new(); + loop { + match self.ctx.receive_packet() { + Ok(p) => { + let (data, seq) = to_sample_data(&p.data)?; + if self.seq_header.is_none() { + self.seq_header = seq; + } + let duration = usize::try_from(p.input_frameno).ok().and_then(|i| self.durations.get(i)).copied().unwrap_or(1); + out.push(Sample { data, key: p.frame_type == FrameType::KEY, duration }); + } + Err(EncoderStatus::Encoded) => {} + Err(EncoderStatus::NeedMoreData | EncoderStatus::LimitReached) => break, + Err(e) => return Err(bad(format!("AV1: {e}"))), + } + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + /// The boxes of an MP4 byte range: (type, body range). + pub fn boxes(d: &[u8], range: std::ops::Range) -> Vec<([u8; 4], std::ops::Range)> { + let mut out = Vec::new(); + let mut p = range.start; + while p + 8 <= range.end { + let size32 = u32::from_be_bytes(d[p..p + 4].try_into().unwrap()) as usize; + let kind: [u8; 4] = d[p + 4..p + 8].try_into().unwrap(); + let (hdr, size) = if size32 == 1 { (16, u64::from_be_bytes(d[p + 8..p + 16].try_into().unwrap()) as usize) } else { (8, size32) }; + assert!(size >= hdr && p + size <= range.end, "box {} overruns", String::from_utf8_lossy(&kind)); + out.push((kind, p + hdr..p + size)); + p += size; + } + assert_eq!(p, range.end, "trailing bytes"); + out + } + + fn find(d: &[u8], r: std::ops::Range, path: &[&[u8; 4]]) -> std::ops::Range { + let mut r = r; + for k in path { + r = boxes(d, r).into_iter().find(|(t, _)| t == *k).unwrap_or_else(|| panic!("no {}", String::from_utf8_lossy(*k))).1; + } + r + } + + fn samples() -> Vec { + // fake AV1 data: an OBU_FRAME (type 6) with a size field + let frame = |b: u8| vec![0x32, 2, b, b]; + vec![ + Sample { data: frame(1), key: true, duration: 30 }, + Sample { data: frame(2), key: false, duration: 1 }, + Sample { data: frame(3), key: false, duration: 1 }, + Sample { data: frame(4), key: true, duration: 58 }, + ] + } + + #[test] + fn obus_split_and_temporal_delimiters_go() { + // TD, sequence header (3 bytes), frame (2 bytes) + let tu = [0x12, 0, 0x0a, 3, 1, 2, 3, 0x32, 2, 9, 9]; + let o = obus(&tu).unwrap(); + assert_eq!(o.iter().map(|(k, _)| *k).collect::>(), vec![2, 1, 6]); + let (data, seq) = to_sample_data(&tu).unwrap(); + assert_eq!(data, &tu[2..]); + assert_eq!(seq.unwrap(), &tu[2..7]); + // hostile: sizes past the end, forbidden bit, endless LEB128 + assert!(obus(&[0x32, 9, 1]).is_err()); + assert!(obus(&[0x80]).is_err()); + assert!(obus(&[0x32, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]).is_err()); + assert!(obus(&[0x36]).is_err(), "extension byte missing"); + assert_eq!(av1_config(&[1, 2])[..], [0x81, 31, 0x0c, 0, 1, 2]); + } + + #[test] + fn mp4_box_tree_samples_and_durations() { + let mut m = Mp4Writer::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + assert!(m.add(&Sample { data: vec![1], key: false, duration: 1 }).is_err(), "must start on a key frame"); + for s in samples() { + m.add(&s).unwrap(); + } + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap().finish().is_err(), "no frames"); + m.set_config(av1_config(&[0x0a, 1, 0])); + let d = m.finish().unwrap().into_inner(); + let top = boxes(&d, 0..d.len()); + assert_eq!(top.iter().map(|(k, _)| *k).collect::>(), vec![*b"ftyp", *b"mdat", *b"moov"]); + assert_eq!(&d[top[1].1.clone()], &[0x32, 2, 1, 1, 0x32, 2, 2, 2, 0x32, 2, 3, 3, 0x32, 2, 4, 4]); + let stbl = find(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"minf", b"stbl"]); + let kinds: Vec<_> = boxes(&d, stbl.clone()).into_iter().map(|(k, _)| k).collect(); + assert_eq!(kinds, vec![*b"stsd", *b"stts", *b"stss", *b"stsc", *b"stsz", *b"stco"]); + let be = |r: usize| u32::from_be_bytes(d[r..r + 4].try_into().unwrap()); + let stsz = find(&d, stbl.clone(), &[b"stsz"]); + assert_eq!(be(stsz.start + 8), 4, "sample count"); + let stts = find(&d, stbl.clone(), &[b"stts"]); + assert_eq!(be(stts.start + 4), 3, "runs: 30, 1×2, 58"); + let total: u32 = (0..3).map(|i| be(stts.start + 8 + i * 8) * be(stts.start + 12 + i * 8)).sum(); + assert_eq!(total, 90 * 512); + let stss = find(&d, stbl.clone(), &[b"stss"]); + assert_eq!((be(stss.start + 4), be(stss.start + 8), be(stss.start + 12)), (2, 1, 4)); + let stco = find(&d, stbl.clone(), &[b"stco"]); + assert_eq!(be(stco.start + 8) as usize, top[1].1.start, "chunk offset = mdat data"); + let stsd = find(&d, stbl, &[b"stsd"]); + let av01 = boxes(&d, stsd.start + 8..stsd.end); + assert_eq!(av01[0].0, *b"av01"); + let entry = av01[0].1.clone(); + assert_eq!(u16::from_be_bytes([d[entry.start + 24], d[entry.start + 25]]), 64); + let inner: Vec<_> = boxes(&d, entry.start + 78..entry.end).into_iter().map(|(k, _)| k).collect(); + assert_eq!(inner, vec![*b"av1C", *b"colr", *b"pasp"]); + let mdhd = find(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"mdhd"]); + assert_eq!((be(mdhd.start + 12), be(mdhd.start + 16)), (30 * 512, 90 * 512)); + let mvhd = find(&d, 0..d.len(), &[b"moov", b"mvhd"]); + assert_eq!(be(mvhd.start + 16), 3000, "3 s in ms"); + } + + /// The EBML elements of a byte range: (id, body range). + fn elements(d: &[u8], range: std::ops::Range) -> Vec<(u32, std::ops::Range)> { + let vint = |p: usize| -> (u64, usize) { + let len = d[p].leading_zeros() as usize + 1; + let mut v = u64::from(d[p]) & (0xff >> len); + for i in 1..len { + v = (v << 8) | u64::from(d[p + i]); + } + (v, len) + }; + let mut out = Vec::new(); + let mut p = range.start; + while p < range.end { + let idlen = d[p].leading_zeros() as usize + 1; + let id = d[p..p + idlen].iter().fold(0u32, |a, b| (a << 8) | u32::from(*b)); + let (size, n) = vint(p + idlen); + let start = p + idlen + n; + let end = start + size as usize; + assert!(end <= range.end, "element {id:x} overruns"); + out.push((id, start..end)); + p = end; + } + out + } + + #[test] + fn webm_element_tree_blocks_and_duration() { + let mut m = WebmWriter::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + assert!(m.add(&Sample { data: vec![0x32, 0], key: true, duration: 1 }).is_err(), "configuration first"); + let mut m = WebmWriter::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + m.set_config(av1_config(&[0x0a, 1, 0])); + for s in samples() { + m.add(&s).unwrap(); + } + let d = m.finish().unwrap().into_inner(); + let top = elements(&d, 0..d.len()); + assert_eq!(top.iter().map(|(i, _)| *i).collect::>(), vec![EBML, SEGMENT]); + let seg = elements(&d, top[1].1.clone()); + let ids: Vec<_> = seg.iter().map(|(i, _)| *i).collect(); + assert_eq!(ids, vec![SEEK_HEAD, VOID, INFO, TRACKS, CLUSTER, CLUSTER, CUES]); + let info = elements(&d, seg[2].1.clone()); + let dur = info.iter().find(|(i, _)| *i == DURATION).unwrap().1.clone(); + assert_eq!(f64::from_be_bytes(d[dur].try_into().unwrap()), 3000.0); + let track = elements(&d, elements(&d, seg[3].1.clone())[0].1.clone()); + let codec = track.iter().find(|(i, _)| *i == 0x86).unwrap().1.clone(); + assert_eq!(&d[codec], b"V_AV1"); + let first: Vec<_> = elements(&d, seg[4].1.clone()).into_iter().map(|(i, _)| i).collect(); + assert_eq!(first, vec![TIMESTAMP, SIMPLE_BLOCK, SIMPLE_BLOCK, SIMPLE_BLOCK]); + let second = elements(&d, seg[5].1.clone()); + assert_eq!(d[second[0].1.clone()], [0x04, 0x2a], "the second cluster starts at 1066 ms (32 periods)"); + let group = elements(&d, second[1].1.clone()); + assert_eq!(group.iter().map(|(i, _)| *i).collect::>(), vec![BLOCK, BLOCK_DURATION]); + // the SeekHead points at Info, Tracks and Cues + let seg_start = top[1].1.start; + for seek in elements(&d, seg[0].1.clone()) { + let e = elements(&d, seek.1); + let pos = d[e[1].1.clone()].iter().fold(0usize, |a, b| (a << 8) | usize::from(*b)); + let id = d[e[0].1.clone()].iter().fold(0u32, |a, b| (a << 8) | u32::from(*b)); + assert!(seg.iter().any(|(i, r)| *i == id && r.start > seg_start + pos), "{id:x}"); + } + let mut neg = Vec::new(); + el_int_into(&mut neg, REFERENCE_BLOCK, -33); + assert_eq!(neg, vec![0xfb, 0x81, 0xdf]); + assert_eq!(ebml_size(126), vec![0xfe]); + assert_eq!(ebml_size(127), vec![0x40, 0x7f]); + } + + #[test] + fn hostile_sizes_are_refused() { + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 0, 10, 30).is_err()); + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 70_000, 10, 30).is_err()); + assert!(WebmWriter::new(Cursor::new(Vec::new()), 10, 10, 0).is_err()); + assert!(yuv420(&[[0; 4]; 3], 2, 2).is_err()); + assert!(yuv420(&[[0; 4]; 9], 3, 3).is_err()); + let (y, u, v) = yuv420(&[[255; 4]; 4], 2, 2).unwrap(); + assert_eq!((y, u, v), (vec![235; 4], vec![128], vec![128])); + assert!(quantizer(100) < quantizer(50) && quantizer(50) < quantizer(1)); + assert!(Av1Encoder::new(&VideoConfig { width: 8, height: 8, fps: 30, quality: 50, speed: 10 }).is_err()); + } + + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + #[test] + fn encodes_av1_into_a_playable_mp4() { + let c = VideoConfig { width: 64, height: 48, fps: 24, quality: 60, speed: 10 }; + let mut e = Av1Encoder::new(&c).unwrap(); + let mut out = Vec::new(); + for (i, periods) in [(0u8, 24u64), (80, 1), (160, 1), (240, 30)] { + let img = vec![[i, 255 - i, 128, 255]; 64 * 48]; + out.extend(e.push(&img, periods, periods > 1).unwrap()); + } + out.extend(e.finish().unwrap()); + assert_eq!(out.len(), 4, "one sample per frame sent"); + assert!(out[0].key); + assert_eq!(out.iter().map(|s| s.duration).sum::(), 56); + for s in &out { + assert!(obus(&s.data).unwrap().iter().all(|(k, _)| *k != OBU_TEMPORAL_DELIMITER)); + } + let config = e.config_record().unwrap(); + assert_eq!(obus(&config[4..]).unwrap()[0].0, OBU_SEQUENCE_HEADER); + let mut m = VideoWriter::new(Cursor::new(Vec::new()), Container::Mp4, 64, 48, 24).unwrap(); + m.set_config(config); + for s in &out { + m.add(s).unwrap(); + } + let d = m.finish().unwrap().into_inner(); + assert_eq!(boxes(&d, 0..d.len()).len(), 3); + } +} diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/corpus.rs index d3b2ceba37..9a86bc5e24 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/develop/src/presets.rs b/crates/develop/src/presets.rs index acaa303fce..85f27d321b 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::borrow::Cow; use crate::DevelopSettings; @@ -185,8 +186,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { /// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped /// through the control specs. pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { - let base = s.to_json(); - let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) }; + let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) }; // (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`) let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone()); for c in crate::controls::CONTROLS { diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 1895776b3a..a40f094c25 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -900,11 +900,24 @@ pub struct BrushStroke { pub density: f64, pub erase: bool, pub auto_mask: bool, + /// Pen pressure (0..1) at each of `points`, recorded when the brush's pressure switch is on: it scales each dab's + /// radius and flow. Empty (the default, a mouse or the switch off) is full pressure everywhere; a point past the + /// end of a shorter list takes the last value. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pressure: Vec, +} + +impl BrushStroke { + /// The pressure at point `i` (0..1; 1 without pressure data, a hostile value clamped, NaN as 1). + pub fn pressure_at(&self, i: usize) -> f32 { + let p = self.pressure.get(i).or(self.pressure.last()).copied().unwrap_or(1.0); + if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) } + } } impl Default for BrushStroke { fn default() -> Self { - Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false } + Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false, pressure: Vec::new() } } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 2229c42a9c..ed20cd6057 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -29,6 +29,7 @@ lightcraft-faces = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } lightcraft-geocode = { workspace = true } +lightcraft-slideshow = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -51,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/build.rs b/crates/engine/build.rs index e097889fff..056c7adc8c 100644 --- a/crates/engine/build.rs +++ b/crates/engine/build.rs @@ -3,7 +3,7 @@ //! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this //! script only reads a local directory (no network). //! -//! wasm32 (the web build) embeds only the UI font, BIZ UDPGothic Regular (~4.5 MB), to keep the +//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the //! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export //! watermark falls back to it there. Native builds embed every font. use std::fmt::Write as _; @@ -69,6 +69,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result { let mut out = String::new(); let mut has_japanese_ui = false; let mut has_chinese_ui = false; + let mut has_thai_ui = false; for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { @@ -76,7 +77,8 @@ fn craft_fonts(dir: &Path, required: bool) -> Result { }; has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular"); has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans"); - if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") { + has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai"); + if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; @@ -89,6 +91,9 @@ fn craft_fonts(dir: &Path, required: bool) -> Result { path.display().to_string(), ); } + if required && !has_thai_ui { + return Err("required Thai UI face Noto Sans Thai Regular (Thai) is missing from fonts/manifest.txt".into()); + } if required && !has_japanese_ui { return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into()); } diff --git a/crates/engine/examples/raw_frame.rs b/crates/engine/examples/raw_frame.rs new file mode 100644 index 0000000000..ce6c63e851 --- /dev/null +++ b/crates/engine/examples/raw_frame.rs @@ -0,0 +1,499 @@ +//! Measure where a camera's own JPEG sits inside its raw sensor data, and which edge bands of that data hold no +//! picture: `cargo xtask raw-frame [--json] FILE…` (or `cargo run --release -p lightcraft-engine --example raw_frame -- +//! [--json] FILE…`). It checks the crop any decoder applies against the camera's own framing, and measures the entries +//! of Nikon `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`) for bodies that record +//! no crop. It works for any colour-filter raw that embeds a full-size camera JPEG (checked on Nikon, Sony, Canon CR2 / +//! CR3, Pentax and Samsung files, where it finds the decoder's crop); bodies with a smaller embedded preview (most +//! Fujifilm, Panasonic and Olympus files) get the edge bands only. +//! +//! **Frame.** For each raw it aligns the largest embedded JPEG with the sensor data: both are reduced to half-size +//! luminance (the raw by summing each 2×2 colour-filter block), and every offset that keeps the JPEG inside the data is +//! scored by the correlation of their gradient magnitudes. The best offset is refined to sub-pixel precision; a peak +//! that is weak, ambiguous or between two pixels is flagged. Files are grouped by model and data size (bit depths +//! pooled: both read out the same area); when 3 clean files agree, the group prints its table entries (siblings of the +//! same readout that agree on the same frame count towards the 3; see `MIN_FILES`). Use several +//! files with detail across the frame: a blank sky, fog or a lens cap has nothing to align on (low score). The JPEG +//! must be the camera's full-size one, unrotated and without in-camera geometry corrections (distortion control off); +//! a smaller preview is not aligned. +//! +//! **Edge bands.** From each edge inwards (at most 128 lines) it counts lines that hold no picture: *empty or dark* +//! (mean below 1% of the range over black) or *masked* (flat, patternless and above black: even and odd samples +//! alike within 4 standard errors or within 0.4% of white, since some filler holds a small fixed offset between the +//! two, as on the D90 and D5000; spread no more than the line's own neighbour noise and under 0.4% of white). A +//! band that the decoder's crop still includes is developed as picture: the coloured bars of issue #976. This is a +//! survey aid: a dark band can also be deep shadow, so read it with the frame. +use std::collections::BTreeMap; + +use lightcraft_codecs::DecodeOptions; +use lightcraft_raw::RawData; + +/// Smallest correlation peak worth trusting, and how far it must stand above the best offset outside its 3×3 +/// neighbourhood (measured: 0.75–0.88 on detailed D5100 / D7000 frames, runners-up ≥ 0.05 lower). +const MIN_SCORE: f64 = 0.5; +const MIN_MARGIN: f64 = 0.02; +/// Largest sub-pixel distance from a whole pixel accepted as a clean alignment. +const MAX_FRACTION: f64 = 0.35; +/// Search at most this many half-size offsets in each direction (384 px of margin around the JPEG: Canon bodies keep +/// up to ~280 px of masked border). +const MAX_SEARCH: usize = 192; +/// Windows of up to this many offsets are searched exhaustively (every Nikon body); larger ones coarse to fine. +const FULL_SEARCH: usize = 2048; +/// Edge bands are counted up to this many lines from each edge. +const MAX_BAND: usize = 128; +/// Clean, agreeing files an entry needs: of one model and data size (bit depths pooled: both read out the same area), +/// or, with at least one of its own, together with sibling models of the same readout (data and JPEG size) whose own +/// files agree on the same frame. +const MIN_FILES: usize = 3; + +struct Frame { + jpeg: (usize, usize), + /// Sub-pixel offset of the JPEG's top left corner in sensor pixels. + at: (f64, f64), + score: f64, + margin: f64, +} + +impl Frame { + fn whole(&self) -> (usize, usize) { + (self.at.0.round().max(0.0) as usize, self.at.1.round().max(0.0) as usize) + } + fn clean(&self) -> bool { + let frac = |v: f64| (v - v.round()).abs(); + self.score >= MIN_SCORE && self.margin >= MIN_MARGIN && frac(self.at.0) <= MAX_FRACTION && frac(self.at.1) <= MAX_FRACTION + } +} + +/// Lines without picture at one edge: empty or dark ones, then masked ones (or the other way round), from the edge. +#[derive(Clone, Copy, Default)] +struct Band { + dark: usize, + masked: usize, +} + +impl Band { + fn total(self) -> usize { + self.dark + self.masked + } +} + +struct Measured { + model: String, + size: (usize, usize), + bits: u32, + black: f64, + white: f64, + /// The crop the decoder applies, in sensor-data coordinates: x, y, width, height. + crop: (usize, usize, usize, usize), + /// Left, right, top, bottom. + bands: [Band; 4], + frame: Result, + /// With a clean frame: what the crop holds beyond it. + beyond: Option<[Beyond; 4]>, +} + +impl Measured { + /// Lines of each edge band (left, right, top, bottom) inside the decoder's crop: developed as picture. + fn in_crop(&self) -> [usize; 4] { + let (w, h) = self.size; + let (x, y, cw, ch) = self.crop; + let [l, r, t, b] = self.bands.map(Band::total); + [l.saturating_sub(x), (x + cw).saturating_sub(w - r.min(w)), t.saturating_sub(y), (y + ch).saturating_sub(h - b.min(h))] + } +} + +/// What the decoder's crop holds outside the camera's JPEG frame, per side (left, right, top, bottom): lines that are +/// masked (flat above black: a coloured bar), dark (empty or optical black: a dark edge) or picture (trimmed by the +/// camera, harmless). +#[derive(Clone, Copy, Default)] +struct Beyond { + masked: usize, + dark: usize, + picture: usize, +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let json = args.iter().any(|a| a == "--json"); + let files: Vec<&String> = args.iter().filter(|a| *a != "--json").collect(); + if files.is_empty() { + eprintln!("usage: raw_frame [--json] FILE… (raw files whose largest embedded JPEG is the camera's full-size one)"); + std::process::exit(2); + } + // model, data size, JPEG size -> (bit depth, frame) of each file + let mut groups: BTreeMap<(String, (usize, usize), (usize, usize)), Vec<(u32, Frame)>> = BTreeMap::new(); + for f in files { + let m = match measure(f) { + Ok(m) => m, + Err(e) => { + if json { + println!("{}", serde_json::json!({ "file": f, "error": e })); + } else { + println!("{f}: skipped: {e}"); + } + continue; + } + }; + let in_crop = m.in_crop(); + if json { + let band = |b: Band| serde_json::json!({ "dark": b.dark, "masked": b.masked }); + let frame = match &m.frame { + Ok(fr) => serde_json::json!({ + "jpeg": [fr.jpeg.0, fr.jpeg.1], "at": [fr.at.0, fr.at.1], "score": fr.score, "margin": fr.margin, "clean": fr.clean() + }), + Err(e) => serde_json::json!({ "error": e }), + }; + println!( + "{}", + serde_json::json!({ + "file": f, "model": m.model, "size": [m.size.0, m.size.1], "bits": m.bits, "black": m.black, "white": m.white, + "crop": [m.crop.0, m.crop.1, m.crop.2, m.crop.3], + "bands": { "left": band(m.bands[0]), "right": band(m.bands[1]), "top": band(m.bands[2]), "bottom": band(m.bands[3]) }, + "in_crop": { "left": in_crop[0], "right": in_crop[1], "top": in_crop[2], "bottom": in_crop[3] }, + "beyond_frame": m.beyond.map(|b| { + let side = |b: Beyond| serde_json::json!({ "masked": b.masked, "dark": b.dark, "picture": b.picture }); + serde_json::json!({ "left": side(b[0]), "right": side(b[1]), "top": side(b[2]), "bottom": side(b[3]) }) + }), + "frame": frame, + }) + ); + } else { + let frame = match &m.frame { + Ok(fr) => format!( + "JPEG {}×{} at ({:.2}, {:.2}), score {:.3}, margin {:.3}{}", + fr.jpeg.0, + fr.jpeg.1, + fr.at.0, + fr.at.1, + fr.score, + fr.margin, + if fr.clean() { "" } else { " (not clean: weak, ambiguous or between pixels)" } + ), + Err(e) => format!("no frame: {e}"), + }; + println!("{f}: {} {}×{} {}-bit, {frame}", m.model, m.size.0, m.size.1, m.bits); + let side = |name: &str, b: Band| match (b.dark, b.masked) { + (0, 0) => None, + (d, 0) => Some(format!("{name} {d} dark")), + (0, k) => Some(format!("{name} {k} masked")), + (d, k) => Some(format!("{name} {d} dark + {k} masked")), + }; + let bands: Vec = ["left", "right", "top", "bottom"].iter().zip(m.bands).filter_map(|(n, b)| side(n, b)).collect(); + let shown: Vec = + ["left", "right", "top", "bottom"].iter().zip(in_crop).filter(|(_, n)| *n > 0).map(|(s, n)| format!("{s} {n}")).collect(); + println!( + " edge bands: {}; decoder crop ({}, {}, {}, {}){}", + if bands.is_empty() { "none".to_string() } else { bands.join(", ") }, + m.crop.0, + m.crop.1, + m.crop.2, + m.crop.3, + match m.beyond { + Some(b) => { + let parts: Vec = ["left", "right", "top", "bottom"] + .iter() + .zip(b) + .filter(|(_, b)| b.masked + b.dark + b.picture > 0) + .map(|(s, b)| format!("{s} {} masked / {} dark / {} picture", b.masked, b.dark, b.picture)) + .collect(); + if parts.is_empty() { + "; nothing beyond the camera frame".to_string() + } else { + format!("; beyond the camera frame: {}", parts.join(", ")) + } + } + None if shown.is_empty() => String::new(), + None => format!(" still includes edge bands: {} lines", shown.join(", ")), + } + ); + } + if let Ok(fr) = m.frame { + groups.entry((m.model.clone(), m.size, fr.jpeg)).or_default().push((m.bits, fr)); + } + } + if json { + return; + } + println!(); + // each group's agreed frame: (model, data size, JPEG size) -> (offset, clean files), when its clean files agree + let agreed: BTreeMap<&(String, (usize, usize), (usize, usize)), ((usize, usize), usize)> = groups + .iter() + .filter_map(|(key, frames)| { + let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect(); + let first = *offsets.first()?; + offsets.iter().all(|o| *o == first).then_some((key, (first, offsets.len()))) + }) + .collect(); + for (key, frames) in &groups { + let (model, size, jpeg) = key; + let clean = frames.iter().filter(|(_, f)| f.clean()).count(); + let mut depths: Vec = frames.iter().map(|(b, _)| *b).collect(); + depths.sort_unstable(); + depths.dedup(); + let label = format!("{model} {}×{}", size.0, size.1); + let Some(&((x, y), _)) = agreed.get(key) else { + if clean == 0 { + println!("{label}: no clean alignment in {} files; no entry", frames.len()); + } else { + let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect(); + println!("{label}: files disagree: {offsets:?}; no entry"); + } + continue; + }; + let note = if clean < frames.len() { format!(", {} not clean", frames.len() - clean) } else { String::new() }; + println!("{label}: {clean} of {} files agree on ({x}, {y}){note}", frames.len()); + // siblings: other models whose sensor readout matches (same data and JPEG size) and whose own files agree on + // the same frame + let siblings: Vec<(&str, usize)> = agreed + .iter() + .filter(|((m, s, j), (o, _))| m != model && s == size && j == jpeg && *o == (x, y)) + .map(|((m, _, _), (_, n))| (m.as_str(), *n)) + .collect(); + let pooled = clean + siblings.iter().map(|(_, n)| n).sum::(); + if clean < MIN_FILES { + if pooled < MIN_FILES { + println!(" pending: {} more clean file(s) needed for an entry", MIN_FILES - pooled); + continue; + } + let names: Vec = siblings.iter().map(|(m, n)| format!("{m} ({n})")).collect(); + println!(" with siblings of the same readout and frame: {}", names.join(", ")); + } + for bits in depths { + println!( + " MeasuredBody {{ model: {model:?}, size: ({}, {}), bits: {bits}, crop: ({x}, {y}, {}, {}), white: None }},", + size.0, size.1, jpeg.0, jpeg.1 + ); + } + } +} + +fn measure(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let raw = lightcraft_raw::decode(&bytes).map_err(|e| e.to_string())?; + let RawData::U16(d) = &raw.data else { return Err("not 16-bit integer sensor data".into()) }; + if raw.cpp != 1 || raw.cfa.is_none() { + return Err("not colour-filter sensor data".into()); + } + let (w, h) = (raw.width, raw.height); + if w < 4 || h < 4 || d.len() < w.saturating_mul(h) { + return Err("sensor data shorter than its size".into()); + } + let black = f64::from(raw.black.values.iter().sum::() / raw.black.values.len().max(1) as f32).max(0.0); + let white = f64::from(raw.white.first().copied().unwrap_or(((1u32 << raw.bits.min(16)) - 1) as f32)).max(black + 1.0); + let at = |x: usize, y: usize| f64::from(d.get(y * w + x).copied().unwrap_or(0)); + let bands = [ + band((0..w.min(MAX_BAND)).map(|x| line(h, |i| at(x, i))), black, white), + band((0..w.min(MAX_BAND)).map(|k| line(h, |i| at(w - 1 - k, i))), black, white), + band((0..h.min(MAX_BAND)).map(|y| line(w, |i| at(i, y))), black, white), + band((0..h.min(MAX_BAND)).map(|k| line(w, |i| at(i, h - 1 - k))), black, white), + ]; + // the decoder's crop is relative to the active area: in sensor-data coordinates here + let a = raw.active_area; + let c = lightcraft_raw::Rect::new(a.x + raw.crop.x, a.y + raw.crop.y, raw.crop.width, raw.crop.height); + let frame = frame(&bytes, d, w, h); + let beyond = frame.as_ref().ok().filter(|f| f.clean()).map(|f| { + let ((fx, fy), (fw, fh)) = (f.whole(), f.jpeg); + let kinds = |lines: Vec| { + lines.iter().fold(Beyond::default(), |mut b, l| { + match kind(l, black, white) { + Kind::Dark => b.dark += 1, + Kind::Masked => b.masked += 1, + Kind::Picture => b.picture += 1, + } + b + }) + }; + let col = |x: usize| line(h, |i| at(x, i)); + let row = |y: usize| line(w, |i| at(i, y)); + [ + kinds((c.x..fx.min(c.x + c.width)).map(col).collect()), + kinds(((fx + fw).max(c.x)..c.x + c.width).map(col).collect()), + kinds((c.y..fy.min(c.y + c.height)).map(row).collect()), + kinds(((fy + fh).max(c.y)..c.y + c.height).map(row).collect()), + ] + }); + Ok(Measured { + model: raw.metadata.model.clone().unwrap_or_default().trim().to_string(), + size: (w, h), + bits: raw.bits, + black, + white, + crop: (c.x, c.y, c.width, c.height), + bands, + frame, + beyond, + }) +} + +/// Statistics of one line of sensor samples: mean, |even − odd| mean, spread, neighbour noise (same colour, 2 apart) +/// and the number of sample pairs. +struct Line { + mean: f64, + pattern: f64, + spread: f64, + noise: f64, + pairs: f64, +} + +fn line(n: usize, at: impl Fn(usize) -> f64) -> Line { + let stride = ((n / 512).max(1)).div_ceil(2) * 2; + let (mut k, mut even, mut odd, mut var, mut noise) = (0f64, 0f64, 0f64, 0f64, 0f64); + let idx: Vec = (0..n.saturating_sub(2)).step_by(stride).collect(); + for &i in &idx { + even += at(i); + odd += at(i + 1); + k += 1.0; + } + let (even, odd) = (even / k.max(1.0), odd / k.max(1.0)); + for &i in &idx { + var += (at(i) - even).powi(2); + noise += (at(i + 2) - at(i)).powi(2) / 2.0; + } + Line { mean: (even + odd) / 2.0, pattern: (even - odd).abs(), spread: (var / k.max(1.0)).sqrt(), noise: (noise / k.max(1.0)).sqrt(), pairs: k } +} + +enum Kind { + Dark, + Masked, + Picture, +} + +fn kind(l: &Line, black: f64, white: f64) -> Kind { + let range = white - black; + let slack = white / 4096.0; + if l.mean < black + range * 0.01 { + Kind::Dark + } else if l.mean > black + range * 0.02 + && l.mean < white * 0.25 + // patternless within the noise, or with a small fixed offset between the CFA phases (D90 / D5000 filler) + && (l.pattern < 4.0 * l.noise * (2.0 / l.pairs.max(1.0)).sqrt() + slack || l.pattern < white * 0.004) + && l.spread < 1.5 * l.noise + slack + && l.spread < white * 0.004 + { + Kind::Masked + } else { + Kind::Picture + } +} + +fn band(lines: impl Iterator, black: f64, white: f64) -> Band { + let mut b = Band::default(); + for l in lines { + match kind(&l, black, white) { + Kind::Dark => b.dark += 1, + Kind::Masked => b.masked += 1, + Kind::Picture => break, + } + } + b +} + +fn frame(bytes: &[u8], d: &[u16], w: usize, h: usize) -> Result { + let jpeg = lightcraft_raw::embedded_preview(bytes).ok_or("no embedded preview")?; + let jpeg = lightcraft_codecs::decode(&jpeg, DecodeOptions::default()).map_err(|e| e.to_string())?; + let (jw, jh) = (jpeg.image.width, jpeg.image.height); + if jw > w || jh > h || jw * 10 < w * 9 || jh * 10 < h * 9 { + return Err(format!("the largest preview ({jw}×{jh}) is not a full-size JPEG of the {w}×{h} data")); + } + // half-size luminance, square-rooted so the linear raw and the JPEG weigh their tones alike + let (rw, rh) = (w / 2, h / 2); + let raw_y: Vec = (0..rh * rw) + .map(|i| { + let (x, y) = (2 * (i % rw), 2 * (i / rw)); + let s: f64 = + [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)].iter().map(|&(x, y)| f64::from(d.get(y * w + x).copied().unwrap_or(0))).sum(); + s.sqrt() + }) + .collect(); + let (pw, ph) = (jw / 2, jh / 2); + let px = &jpeg.image.data; + let jpg_y: Vec = (0..ph * pw) + .map(|i| { + let (x, y) = (2 * (i % pw), 2 * (i / pw)); + let s: f64 = [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)] + .iter() + .map(|&(x, y)| px.get(y * jw + x).map_or(0.0, |p| f64::from(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]).max(0.0))) + .sum(); + s.sqrt() + }) + .collect(); + let (sx, sy) = ((rw - pw).min(MAX_SEARCH), (rh - ph).min(MAX_SEARCH)); + let grad = |img: &[f64], stride: usize, x: usize, y: usize| { + let at = |x: usize, y: usize| img.get(y * stride + x).copied().unwrap_or(0.0); + (at(x + 1, y) - at(x, y)).hypot(at(x, y + 1) - at(x, y)) + }; + let normalise = |v: Vec| { + let n = v.len().max(1) as f64; + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|a| (a - mean).powi(2)).sum::() / n).sqrt().max(1e-12); + v.into_iter().map(|a| (a - mean) / sd).collect::>() + }; + // gradient magnitudes on every `step`-th half-size pixel, normalised, and the correlation at one offset + let sample = |step: usize| { + let points: Vec<(usize, usize)> = + (0..ph.saturating_sub(1)).step_by(step).flat_map(|y| (0..pw.saturating_sub(1)).step_by(step).map(move |x| (x, y))).collect(); + let gj = normalise(points.iter().map(|&(x, y)| grad(&jpg_y, pw, x, y)).collect()); + (points, gj) + }; + let score = |(points, gj): &(Vec<(usize, usize)>, Vec), dx: usize, dy: usize| { + let gr = normalise(points.iter().map(|&(x, y)| grad(&raw_y, rw, x + dx, y + dy)).collect()); + gr.iter().zip(gj).map(|(a, b)| a * b).sum::() / gj.len().max(1) as f64 + }; + let fine = sample(2); + // every offset when the window is small (every Nikon body); else every 4th on sparser points, then each offset + // around the best of those + let mut scores = vec![vec![f64::NEG_INFINITY; sx + 1]; sy + 1]; + let mut coarse_best = f64::NEG_INFINITY; + let (mut cx, mut cy) = (0, 0); + let window = if (sx + 1) * (sy + 1) <= FULL_SEARCH { + (0..=sx, 0..=sy) + } else { + let coarse = sample(8); + for dy in (0..=sy).step_by(4) { + for dx in (0..=sx).step_by(4) { + let v = score(&coarse, dx, dy); + if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) { + *s = v; + } + if v > coarse_best { + (coarse_best, cx, cy) = (v, dx, dy); + } + } + } + (cx.saturating_sub(5)..=(cx + 5).min(sx), cy.saturating_sub(5)..=(cy + 5).min(sy)) + }; + for dy in window.1.clone() { + for dx in window.0.clone() { + if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) { + *s = score(&fine, dx, dy); + } + } + } + let at = |dx: usize, dy: usize| scores.get(dy).and_then(|r| r.get(dx)).copied().unwrap_or(f64::NEG_INFINITY); + let (mut bx, mut by) = (0, 0); + for dy in 0..=sy { + for dx in 0..=sx { + if at(dx, dy) > at(bx, by) { + (bx, by) = (dx, dy); + } + } + } + let best = at(bx, by); + let runner_up = (0..=sy) + .flat_map(|dy| (0..=sx).map(move |dx| (dx, dy))) + .filter(|&(dx, dy)| dx.abs_diff(bx) > 1 || dy.abs_diff(by) > 1) + .map(|(dx, dy)| at(dx, dy)) + .fold(f64::NEG_INFINITY, f64::max); + // parabola through the peak and its neighbours (none at the window's edge) + let refine = |a: f64, b: f64, c: f64| { + let k = a - 2.0 * b + c; + if a.is_finite() && c.is_finite() && k < 0.0 { (0.5 * (a - c) / k).clamp(-0.5, 0.5) } else { 0.0 } + }; + let fx = if bx > 0 { refine(at(bx - 1, by), best, at(bx + 1, by)) } else { 0.0 }; + let fy = if by > 0 { refine(at(bx, by - 1), best, at(bx, by + 1)) } else { 0.0 }; + Ok(Frame { + jpeg: (jw, jh), + at: (2.0 * (bx as f64 + fx), 2.0 * (by as f64 + fy)), + score: best, + margin: if runner_up.is_finite() { best - runner_up } else { best }, + }) +} diff --git a/crates/engine/examples/sync_scan_bench.rs b/crates/engine/examples/sync_scan_bench.rs new file mode 100644 index 0000000000..3b9bc51c19 --- /dev/null +++ b/crates/engine/examples/sync_scan_bench.rs @@ -0,0 +1,47 @@ +//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and +//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes +//! nothing there, and probes no file (the library is made from the folder's own listing). +//! +//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- ` +use std::time::Instant; + +use lightcraft_catalog::{Op, Photo, Source}; +use lightcraft_engine::Session; +use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with}; + +fn main() { + let Some(folder) = std::env::args().nth(1) else { + eprintln!("usage: sync_scan_bench "); + return; + }; + let mut s = Session::new(); + let t = Instant::now(); + let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None); + println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64()); + for f in &files { + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00"); + if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() { + eprintln!("could not add {name}"); + } + } + for run in 0..2 { + let input = match SyncInput::new(&mut s, &folder, true) { + Ok(input) => input, + Err(e) => { + eprintln!("{e}"); + return; + } + }; + let t = Instant::now(); + let changes = scan_with(input, &SyncProgress::default()); + println!( + "run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)", + t.elapsed().as_secs_f64(), + changes.new.len(), + changes.missing.len(), + changes.metadata.len() + ); + } +} diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs index 6fc6180395..147d20f3a5 100644 --- a/crates/engine/src/activity.rs +++ b/crates/engine/src/activity.rs @@ -73,7 +73,7 @@ impl Unit { pub struct TaskInfo { pub id: u64, /// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`, - /// `faces`, `denoise`, `findMissing`, `applePhotos`. + /// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`, `focus`. pub kind: &'static str, /// English; the frontend translates it. pub label: String, diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c5..614c58c60c 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -30,7 +30,11 @@ use lightcraft_raw::{ /// states, for every make (#778). /// 4: a tone curve from black-and-white camera JPEGs (#577). /// 5: dark JPEG luminance pairs for the camera toe (#1307). -pub const LOOK_VERSION: u32 = 5; +/// 6: the ILCA-99M2 profile. +/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. +/// 8: the bundled NIKON Z 6_2 profile. +/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. +pub const LOOK_VERSION: u32 = 9; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -63,6 +67,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { | RawFormat::Pef | RawFormat::Srw | RawFormat::Orf + | RawFormat::Erf ) } @@ -74,8 +79,42 @@ 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. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)) +/// `lens` 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. +/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit. +pub(crate) fn starting_colour( + raw: &mut RawImage, + bytes: &[u8], + lens: Option<&LensGeometry>, + embedded: Option<&lightcraft_develop::EmbeddedLens>, +) -> (Xy, CameraTransform, Option) { + let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)); + if look.is_none() && dng_tone_eligible(raw, &t) { + let look = fit_dng_tone(raw, bytes, &t, embedded); + 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 @@ -119,8 +158,29 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option Option) -> Option { +pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option { let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes // into the baseline exposure let white = m.apply([1.0; 3]); let k = (white[0] + white[1] + white[2]) / 3.0; - if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) { + if !(k.is_finite() && k > 1e-6 && t.wb.iter().all(|w| w.is_finite() && *w > 0.0)) { return None; } let m = Mat3(m.0.map(|r| r.map(|v| v / k))); let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); let wb = t.wb.map(f64::from); let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); - let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2])); + let (illuminant, color_matrix) = match declared { + crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix), + crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]), + crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]), + }; + if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) { + return None; + } Some(ColorData { - illuminant: [21, 0], - color_matrix: [Some(camera_to_xyz.inverse()?), None], - forward_matrix: [Some(forward), None], + illuminant, + color_matrix, + // a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white + forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)], camera_calibration: [None, None], analog_balance: None, as_shot_neutral: Some(neutral), @@ -163,6 +236,17 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) }) } +/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per +/// channel. `None` when the model gives no positive neutral there. +fn declaring(xy: Xy, neutral: [f64; 3]) -> Option { + let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy); + let n = g.apply(xy.to_xyz()); + if !n.iter().all(|v| v.is_finite() && *v > 1e-9) { + return None; + } + Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g)) +} + /// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the /// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). fn dng_tone_curve(tone: &CameraTone) -> Option { @@ -176,11 +260,11 @@ fn dng_tone_curve(tone: &CameraTone) -> Option { } /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). -pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option, bool) { +pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { return (None, false); } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) }; + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) }; let black_and_white = monochrome(&reference); // A camera profile pooled from many photos knows colours this photo shows too little of; // try its colour first and fit tone/chroma per photo (picture styles vary). @@ -337,18 +421,183 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> 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. -fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec)> { +fn proxies( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + size: usize, + min_fraction: f64, + lens: Option<&LensGeometry>, +) -> Option<(Rgb32f, Rgb32f, Vec)> { let edge = (2 * size).max(384) as u32; let decoded = crate::files::decode_raw_preview( bytes, 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; } @@ -370,6 +619,12 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; let mut sensor = sensor_proxy(raw, k, edge as usize)?; let mut clipped = clip_mask(&sensor); + // The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 % + // zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate, + // kept below when it lines up with the JPEG better than the uncorrected one. + let warped = lens + .filter(|_| raw.format != RawFormat::Cr3) + .and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?))); let gain = 2f32.powf(transform.baseline_exposure as f32); let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); if raw.format == RawFormat::Cr3 { @@ -383,7 +638,15 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); } let mut sensor = fit(&sensor, size, size, Filter::Box); - let reference = reference_at(&reference, &sensor); + let mut reference_small = reference_at(&reference, &sensor); + if let Some((warped, warped_clipped)) = warped { + let candidate = fit(&warped, size, size, Filter::Box); + let candidate_reference = reference_at(&reference, &candidate); + if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) { + (sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped); + } + } + let reference = reference_small; // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like // the reference, so the mask pairs pixel for pixel. @@ -545,16 +808,16 @@ fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rg /// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the /// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own /// colour matrices, other files or unusable previews. -pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option> { +pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option> { if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { 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)?; + 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()) } @@ -565,6 +828,102 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio Some((matrix, fit_hue_sat(pairs, &matrix))) } +/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of +/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without +/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct LensGeometry { + /// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance. + k: [f64; 4], + /// The optical centre, relative to the active area (as the opcode has it). + center: [f64; 2], +} + +pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option { + use lightcraft_raw::Opcode; + let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect(); + if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) { + return None; + } + ops.into_iter().find_map(|op| match op { + Opcode::WarpRectilinear { planes, center } => { + let p = planes.get(1).or(planes.first())?; + let k = [p[0], p[1], p[2], p[3]]; + (k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1])) + .then_some(LensGeometry { k, center: *center }) + } + _ => None, + }) +} + +/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both +/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels. +fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { + let (w, h) = (a.width, a.height); + if (b.width, b.height) != (w, h) || w < 3 || h < 3 { + return -1.0; + } + let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::>(); + let high = |l: &[f64]| -> Vec { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if x == 0 || y == 0 || x + 1 == w || y + 1 == h { + return f64::NAN; + } + let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1]; + l[i] - around.iter().map(|&j| l[j]).sum::() / 8.0 + }) + .collect() + }; + let (ha, hb) = (high(&lum(a)), high(&lum(b))); + let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); + if pairs.len() < 64 { + return -1.0; + } + let n = pairs.len() as f64; + let (ma, mb) = (pairs.iter().map(|p| p.0).sum::() / n, pairs.iter().map(|p| p.1).sum::() / n); + let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum(); + let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum()); + let r = cov / (va * vb).sqrt().max(1e-12); + if r.is_finite() { r } else { -1.0 } +} + +/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is +/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens` +/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another +/// size (the demosaiced fallback of linear raws). +fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option { + let a = raw.active_area; + let c = raw.crop.clipped(a.width, a.height); + let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c }; + let (w, h) = (img.width, img.height); + if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 { + return None; + } + let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0); + let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah); + let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9); + let [k0, k1, k2, k3] = lens.k; + let kf = k as f64; + let (x0, y0) = (c.x as f64, c.y as f64); + let mut out = Rgb32f::new(w, h); + for (i, px) in out.data.iter_mut().enumerate() { + // the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor + let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5); + let (dx, dy) = ((ox - cx) / m, (oy - cy) / m); + let r2 = dx * dx + dy * dy; + let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf); + *px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 { + img.sample_bilinear(sx as f32, sy as f32) + } else { + [f32::NAN; 3] + }; + } + Some(out) +} + fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { Some(sensor) => sensor, @@ -626,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; } @@ -645,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); @@ -658,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. @@ -797,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 @@ -813,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)) }) }) @@ -822,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( @@ -831,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 => { @@ -978,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() { @@ -1052,11 +1421,11 @@ fn monochrome(reference: &Rgb32f) -> bool { /// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and /// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The /// chroma curve stays neutral: the JPEG has no colourfulness to learn. -fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { +fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option { if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; if !monochrome(&reference) { return None; } @@ -1087,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() { @@ -1181,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. @@ -1490,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); @@ -1547,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. @@ -1657,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)); @@ -1848,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) @@ -1952,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); @@ -2000,11 +2452,100 @@ 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(), } } + /// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain + /// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none. + #[test] + fn lens_geometry_reads_the_first_rectilinear_warp() { + use lightcraft_raw::Opcode; + let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear { + planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + center: c, + }; + assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] })); + let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02)); + assert!(lens_geometry(&[]).is_none()); + assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none()); + assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none()); + let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + p[0] = 1.0; + p[18] = 1e6; + let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false }; + assert!(lens_geometry(&[chain, single]).is_none()); + } + + /// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp + /// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside + /// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone. + #[test] + fn the_sensor_proxy_follows_the_cameras_lens_warp() { + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default()); + raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48); + raw.crop = raw.active_area; + // a 1/4 proxy whose pixels hold their own coordinates + let mut img = Rgb32f::new(16, 12); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [(i % 16) as f32, (i / 16) as f32, 1.0]; + } + let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; + let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap(); + assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4))); + // the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor + let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap(); + assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]); + let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap(); + assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite()); + assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none()); + } + + /// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected + /// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every + /// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected. + #[test] + fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(entries) = std::fs::read_dir(&dir) else { + eprintln!("skip: no corpus at {}", dir.display()); + return; + }; + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + let (mut checked, mut gain) = (0, 0.0); + for path in paths { + let Ok(bytes) = std::fs::read(&path) else { continue }; + let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue }; + let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue }; + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { + continue; + } + 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, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens))) + else { + continue; + }; + let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference)); + eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display()); + // (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little) + assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display()); + gain += after - before; + checked += 1; + } + eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64); + assert!(checked == 0 || gain > 0.0); + } + /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit /// is tried. #[test] @@ -2052,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, &[]); + 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); @@ -2111,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, &[]); + 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, &[]).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 @@ -2132,17 +2673,18 @@ 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(); + let lens = lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let own = lightcraft_raw::color::has_matrix(&raw.color); let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); 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); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None); 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}"); @@ -2191,6 +2733,7 @@ mod tests { RawFormat::Pef, RawFormat::Srw, RawFormat::Orf, + RawFormat::Erf, ]; assert!(local.into_iter().all(file_local_look)); // DNG carries its own colour model @@ -2235,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}"), @@ -2249,7 +2792,7 @@ mod tests { assert_eq!(raw.format, RawFormat::Cr3); assert_eq!(raw.info().developed_size(), (6000, 4000)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0; + let look = fit_preview(&raw, &bytes, &transform, None).0; eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2258,31 +2801,31 @@ mod tests { assert_eq!(info.camera_tone.is_some(), look.is_some()); } - /// A public ILCE-7CR sample (skipped without the corpus) takes its colour from the bundled ILCE-7CR profile and - /// passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the photo's - /// own fit (the fallback when a profile is rejected) would not. - #[test] - fn corpus_a7cr_uses_the_bundled_profile() { + /// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model` + /// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the + /// photo's own fit (the fallback when a profile is rejected) would not. + fn corpus_uses_the_bundled_profile(file: &str, model: &str) { let path = std::env::var_os("LIGHTCRAFT_CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/arw-sony-a7cr-lossless-l.arw"); + .join("raw") + .join(file); 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()), }; - let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "ILCE-7CR").expect("ILCE-7CR is built in"); + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in"); let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); - let used = crate::camera_profiles::get("ILCE-7CR").expect("ILCE-7CR profile"); - assert_eq!(*used, bundled, "a local ILCE-7CR profile overrides the built-in one; remove it to run this test"); + let used = crate::camera_profiles::get(model).expect("a profile for the model"); + assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); let raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR")); + assert_eq!(raw.metadata.model.as_deref(), Some(model)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted"); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); for (row, want) in look.matrix.0.iter().zip(expected.0) { for (got, want) in row.iter().zip(want) { @@ -2292,6 +2835,28 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } + #[test] + fn corpus_a7cr_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR"); + } + + #[test] + fn corpus_a99m2_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2"); + } + + /// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to. + #[test] + fn corpus_z6ii_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2"); + } + + /// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to. + #[test] + fn corpus_z8_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8"); + } + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white /// balance is relative to the as-shot look. #[test] @@ -2301,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(); @@ -2321,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}"), @@ -2342,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(); @@ -2361,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(); @@ -2380,7 +2945,7 @@ mod tests { let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); for look in [Some(&look), None] { - let dng = dng_colour(&ColorData::default(), &t, look).unwrap(); + let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap(); let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); for i in 0..3 { assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); @@ -2405,6 +2970,82 @@ mod tests { } } + /// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white + /// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their + /// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8. + #[test] + fn dng_colour_declares_the_white_balance_the_source_shows() { + use crate::files::DngWhite; + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers}; + let tags = ColorData::default(); + let as_shot = [2.5039f32, 1.0, 1.5078]; + let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot))); + let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]); + let tint = look.matrix.apply([1.0; 3]); + assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}"); + let renders_as_shot = |dng: &ColorData| { + let r = camera_transform_of(dng, as_shot_white_of(dng, None)); + assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb); + let m = look.matrix.mul(&t.matrix); + for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] { + let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()); + let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2()); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}"); + } + }; + // a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0) + for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] { + let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}"); + renders_as_shot(&dng); + } + // a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera"); + let mut model = tags.clone(); + assert!(camera.fill(&mut model)); + let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap(); + let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}"); + for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] { + let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w)); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}"); + } + 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 { @@ -2556,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()), @@ -2565,9 +3206,9 @@ mod tests { let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look"); + 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).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(); @@ -2863,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"); } @@ -3126,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 @@ -3155,12 +3798,12 @@ mod tests { tiff }; let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG"); + let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG"); assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); let mid = curve.apply(0.25); assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); - assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white"); + assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white"); } /// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve @@ -3199,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/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 6c35c2a45a..d13023b6e0 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -25,8 +25,12 @@ const MAX_FILE: u64 = 4 << 20; /// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): /// `(model, JSON)`. pub const BUNDLED: &[(&str, &str)] = &[ + ("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")), ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")), + ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), + ("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), ]; @@ -194,10 +198,12 @@ impl Pool { /// NEF or RAF without colour matrices, no usable camera JPEG, too little colour). pub fn add(&mut self, bytes: &[u8]) -> Result, String> { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; - // colour only: geometric lens corrections would stop the sensor proxy from binning + // colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow + // the warp the camera applied to its JPEG) + let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) }; - let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) }; + let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) }; let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1); let entry = self.models.entry(model.to_owned()).or_default(); entry.0 += 1; diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a681ec14bc..6559656889 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -6,11 +6,14 @@ //! Each browse records when the folders it listed were browsed. Untouched records of folders //! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with //! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files -//! and sidecars on disk are never touched, and browsing the folder again brings them back. +//! and sidecars on disk are never touched, and browsing the folder again brings them back. A +//! browse also forgets at once the untouched records of the folder's files that are gone from +//! the disk (deleted or moved by another app), so the folder shows what is on disk. use std::path::Path; -use lightcraft_catalog::{Op, PhotoId}; +use lightcraft_catalog::query::in_folder; +use lightcraft_catalog::{Op, PhotoId, Source}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; @@ -42,11 +45,73 @@ fn browse(s: &mut Session, p: &Value) -> Result { // looking at a folder never brings its deleted photos back (importing the files does) let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() }; let report = import_with(s, &files, &opts)?; + let forgotten = forget_gone(s, &dir_s, subfolders, &files); stamp_browsed(s, &dir_s, &files); s.browse = Some(Browse { path: dir_s.clone(), subfolders }); s.source = LibrarySource::Folder; + s.keep_active_in_view(); let shown = s.visible().len(); - Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()})) + Ok(json!({ + "path": dir_s, + "subfolders": subfolders, + "photos": shown, + "new": report.imported.len(), + "failed": report.failed.len(), + "forgotten": forgotten, + })) +} + +impl Session { + /// Forget the untouched Local records of files gone from the folder `dir` (with its subfolders when + /// `subfolders`) that a browse just listed as `files`: what `library.browse` does after reading a folder, for a + /// UI that reads it on a worker thread instead. Returns how many went. + pub fn forget_gone_from(&mut self, dir: &str, subfolders: bool, files: &[String]) -> usize { + forget_gone(self, dir, subfolders, files) + } +} + +/// Local records in the browsed folder whose files are no longer on disk (deleted or moved by +/// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets +/// them: the folder shows what is on disk, not photos that fail to load (and log an error) on +/// every visit (issue #1392). Records the user changed stay, shown as missing, and so do those +/// the session holds (selected, edited, in the undo history) or with a smart preview. Returns how +/// many went. +fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -> usize { + let listed: std::collections::HashSet<&str> = files.iter().map(String::as_str).collect(); + let held = held(s); + let smart = s.media.smart_dir.clone(); + let gone: Vec = s + .catalog + .photos() + .filter(|p| p.local && !p.deleted && !held.contains(&p.id)) + // a smart preview may be all that is left of the picture + .filter(|p| !smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists())) + .filter(|p| match &p.source { + // a file left out of the listing is checked on disk: another spelling of its path, or + // a listing that missed it, keeps it + Source::File { path } => in_folder(path, dir, subfolders) && !listed.contains(path.as_str()) && !Path::new(path).exists(), + Source::Demo { .. } => false, + }) + .map(|p| p.id) + .collect(); + if gone.is_empty() { + return 0; + } + let mut forgotten = 0; + for chunk in s.catalog.forgettable_local(&gone).chunks(2000) { + let op = Op::Batch { ops: chunk.iter().map(|id| Op::RemovePhoto { id: *id }).collect() }; + match s.catalog.apply(op.clone()) { + Ok(_) => { + s.pending_log.push(op); + forgotten += chunk.len(); + } + Err(e) => log::warn!("forget Local records of files gone from {dir}: {e}"), + } + } + if forgotten > 0 { + log::info!("forgot {forgotten} untouched Local record(s) of files gone from {dir}"); + } + forgotten } /// Folders whose browse time is refreshed at most this often (keeps repeated browsing from @@ -82,7 +147,15 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) { /// Photos the session still needs, whatever their state: shown, selected, being edited, or /// referred to by an undo / redo step. fn in_use(s: &mut Session) -> std::collections::HashSet { - let mut keep: std::collections::HashSet = s.visible().iter().copied().collect(); + let mut keep = held(s); + keep.extend(s.visible().iter().copied()); + keep +} + +/// Photos the session holds whatever is shown: selected, being edited, or referred to by an undo +/// / redo step. +fn held(s: &Session) -> std::collections::HashSet { + let mut keep: std::collections::HashSet = std::collections::HashSet::new(); keep.extend(s.selection.ids.iter().copied()); keep.extend(s.selection.active); keep.extend(s.previous_active); @@ -151,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})); } @@ -337,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/color.rs b/crates/engine/src/cmd/color.rs index 55ea8b5aba..111159e7a2 100644 --- a/crates/engine/src/cmd/color.rs +++ b/crates/engine/src/cmd/color.rs @@ -156,7 +156,7 @@ fn targeted(s: &mut Session, p: &Value) -> Result { } fn index(p: &Value, c: &str) -> Result { - Ok(f64_req(p, "index", c)? as usize) + super::index_req(p, "index", c) } pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb3..eae955cce4 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -35,7 +35,8 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path Some(r) => r(path)?, None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?, }; - let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); drop(bytes); let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() }) .map_err(|e| e.to_string())?; @@ -58,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(); @@ -124,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")); @@ -201,12 +202,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: /// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or /// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different -/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`]. +/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a +/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed +/// again (see [`lightcraft_catalog::Photo::preview_checked`]). pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option { + let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only); let same = info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) - && info.preview_only == ph.preview_only; + && info.preview_only == ph.preview_only + && preview_checked == ph.preview_checked; (!same).then_some(Op::SetContent { id, width: info.width, @@ -214,6 +219,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo file_size: info.file_size, content_hash: info.content_hash, preview_only: info.preview_only, + preview_checked, }) } @@ -234,6 +240,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P m.aperture = m.aperture.or(src.aperture); m.iso = m.iso.or(src.iso); m.gps = m.gps.or(src.gps); + // the camera's focus point: photos imported before it was read get it here + if m.focus.is_none() { + m.focus = src.focus.clone(); + } let mut ops = Vec::new(); if m != before { ops.push(Op::SetMeta { id, meta: Box::new(m) }); @@ -271,10 +281,41 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat (ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) }) } +/// The ops that bring photo `id` and its virtual copies up to date with what their file is now +/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`. +/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it. +pub(crate) fn reload_ops( + cat: &lightcraft_catalog::Catalog, + id: lightcraft_catalog::PhotoId, + info: &crate::media::ProbeInfo, + ops: &mut Vec, + changed: &mut Vec, +) { + let Some(ph) = cat.photo(id) else { return }; + // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; + // nothing already set is overwritten + let meta_ops = fill_missing_meta(id, ph, info); + let mut mine: Vec = + content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect(); + if !mine.is_empty() { + ops.append(&mut mine); + changed.push(id); + } + // virtual copies share the file + for c in cat.photos().filter(|c| c.copy_of == Some(id)) { + let theirs: Vec = + content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect(); + if !theirs.is_empty() { + ops.extend(theirs); + changed.push(c.id); + } + } +} + /// 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()) { @@ -286,25 +327,8 @@ fn reload(s: &mut Session, p: &Value) -> Result { let mut ops = Vec::new(); let mut reloaded = Vec::new(); for ((id, _), info) in paths.into_iter().zip(probed) { - let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; - // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; - // nothing already set is overwritten - let meta_ops = fill_missing_meta(id, ph, &info); - let mut mine: Vec = - content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect(); - if !mine.is_empty() { - ops.append(&mut mine); - reloaded.push(id); - } - // virtual copies share the file - for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { - let theirs: Vec = - content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect(); - if !theirs.is_empty() { - ops.extend(theirs); - reloaded.push(c.id); - } - } + let Ok(info) = info else { continue }; + reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded); } for id in &reloaded { s.media.forget(*id); @@ -320,7 +344,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); @@ -421,6 +445,27 @@ mod tests { assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); } + /// Reload from Disk gives a photo imported before focus points were read its camera focus point, + /// and never replaces one it has. + #[test] + fn reload_fills_a_missing_focus_point() { + let fp = |x: f64| lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 }, + mode: Some("AF-C".into()), + area: None, + tracking: None, + }; + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00"); + ph.captured = Some("2026-10-04T08:16:11".into()); + let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() }; + info.meta.focus = Some(fp(0.3)); + let ops = fill_missing_meta(PhotoId(1), &ph, &info); + let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") }; + assert_eq!(meta.focus, Some(fp(0.3))); + ph.meta.focus = Some(fp(0.1)); + assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept"); + } + fn lens() -> lightcraft_develop::EmbeddedLens { lightcraft_develop::EmbeddedLens { warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), @@ -494,7 +539,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 51049797e2..676519d297 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/curves.rs b/crates/engine/src/cmd/curves.rs index 77b061b789..7388f8e573 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -349,9 +349,8 @@ fn export_presets(s: &mut Session, p: &Value) -> Result { if chosen.is_empty() { return Err(bad(C, "no curve presets to export (save one first, or pass names)")); } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad(C, e))?; + crate::export::write_file_durable(&target, to_file(&chosen).as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 6f064b260a..e3d59c5672 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -5,7 +5,7 @@ use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, Setti use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param}; +use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param}; use crate::{Result, Session, media::SourceLevel}; fn active(s: &Session, c: &str) -> Result { @@ -144,7 +144,7 @@ fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Pre /// Change version `p.index` of the active photo (`f` gets it and the current settings). fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc)) -> Result { let id = active(s, c)?; - let i = f64_req(p, "index", c)? as usize; + let i = index_req(p, "index", c)?; let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; let mut versions = ph.versions.clone(); let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?; @@ -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() @@ -588,14 +588,14 @@ pub fn specs() -> Vec { "Straighten", [], None, - "{angle: degrees -45..45} — keeps the largest crop of the current aspect", + "{angle: degrees -45..45} — keeps the crop if it still fits, else shrinks it about its centre (the largest crop of its aspect stays the largest)", has_active, |s, p| { let id = active(s, "crop.straighten")?; let (w, h) = crop_dims(s, id); let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0); edit(s, "crop.straighten", "Straighten", |d| { - // keep the current crop if it still fits, else the largest of its aspect that does + // keep the current crop if it still fits, else shrink it about its centre until it does d.crop.geometry = d.crop.geometry.with_angle(angle, w, h); Ok(()) }) @@ -678,10 +678,11 @@ pub fn specs() -> Vec { let (w, h) = crop_dims(s, id); edit(s, "crop.rotateAspect", "Rotate Crop", |d| { let r = d.crop.geometry.rect_px(w, h); - let a = r.height() / r.width(); + // a degenerate crop has no aspect to rotate: fall back to the image's own + let a = Some(r.height() / r.width()).filter(|a| a.is_finite() && *a > 0.0); d.crop.aspect = d.crop.aspect.map(|(x, y)| (y, x)); let angle = d.crop.geometry.angle; - d.crop.geometry = crop_fit_angle(w, h, angle, Some(a)); + d.crop.geometry = crop_fit_angle(w, h, angle, a); Ok(()) }) }), @@ -810,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))) @@ -837,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))) @@ -889,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)| { @@ -942,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 { @@ -977,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))) @@ -1060,14 +1062,14 @@ pub fn specs() -> Vec { }), cmd!("version.restore", "Restore Version", [], None, "{index}", has_active, |s, p| { let id = active(s, "version.restore")?; - let i = f64_req(p, "index", "version.restore")? as usize; + let i = index_req(p, "index", "version.restore")?; let v = s.catalog.photo(id).and_then(|ph| ph.versions.get(i).cloned()).ok_or_else(|| bad("version.restore", "no such version"))?; s.set_develop(id, (*v.settings).clone(), &format!("Restore {}", v.name))?; ok() }), cmd!("version.delete", "Delete Version", [], None, "{index}", has_active, |s, p| { let id = active(s, "version.delete")?; - let i = f64_req(p, "index", "version.delete")? as usize; + let i = index_req(p, "index", "version.delete")?; let mut versions = s.catalog.photo(id).map(|ph| ph.versions.clone()).unwrap_or_default(); if i >= versions.len() { return Err(bad("version.delete", "no such version")); diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index 4676d1c65e..711bebb102 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options}; use lightcraft_meta::{Region, RegionKind}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_selection}; +use super::{CommandSpec, always, bad, cmd, has_selection}; use crate::{AiModel, EngineError, Result, Session}; const C: &str = "faces.detect"; @@ -54,10 +54,12 @@ impl NoDetector { } } +/// The YuNet loaded last, and from where. +static LOADED: Mutex)>> = Mutex::new(None); + /// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes). /// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none) pub(crate) fn detector(s: &Session) -> std::result::Result, NoDetector> { - static LOADED: Mutex)>> = Mutex::new(None); let home = s .face_models_dir .as_deref() @@ -82,10 +84,133 @@ pub(crate) fn detector(s: &Session) -> std::result::Result, NoDete let d = Arc::new( Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?, ); + #[cfg(not(target_arch = "wasm32"))] + { + let net = d.clone(); + offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true)); + } *loaded = Some((path, d.clone())); Ok(d) } +/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`. +#[derive(Clone, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GpuState { + /// The background check is still running. + pub checking: bool, + /// The GPU it runs on; `None` when it runs on the CPU. + pub device: Option, + /// Why it runs on the CPU. + pub why: Option, +} + +/// Each model's [`GpuState`], by what it does ("detection", "recognition"). +static GPU_STATE: Mutex> = Mutex::new(Vec::new()); + +pub(crate) fn gpu_state(what: &str) -> Option { + GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone()) +} + +fn set_gpu_state(what: &'static str, st: GpuState) { + let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner); + match all.iter_mut().find(|(w, _)| *w == what) { + Some((_, old)) => *old = st, + None => all.push((what, st)), + } +} + +/// The user chose the CPU for face models (Settings ▸ Faces). +pub(crate) fn cpu_only(s: &Session) -> bool { + s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only) +} + +/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model +/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU. +/// The check shows in the activity stack while it runs. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn offer_gpu( + s: &Session, + what: &'static str, + attach: impl FnOnce(Arc) -> std::result::Result<(), String> + Send + 'static, +) { + if cpu_only(s) { + set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() }); + return; + } + let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No); + task.detail(what); + set_gpu_state(what, GpuState { checking: true, ..GpuState::default() }); + let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || { + let _task = task; + let gpu = match lightcraft_gpu::faces::accelerator() { + Ok(gpu) => gpu, + Err(why) => { + log::info!("faces: {what} runs on the CPU ({why})"); + return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() }); + } + }; + let device = lightcraft_faces::net::Accelerator::device(&*gpu); + match attach(gpu) { + Ok(()) => { + log::info!("faces: {what} runs on {device}"); + set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() }); + } + Err(why) => { + log::info!("faces: {what} stays on the CPU: {why}"); + set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() }); + } + } + }); + if let Err(e) = spawned { + log::warn!("faces: could not check the GPU for {what}: {e}"); + set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() }); + } +} + +/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU. +fn gpu(s: &mut Session, p: &Value) -> Result { + const C: &str = "faces.gpu"; + if let Some(cpu) = p.get("cpuOnly") { + let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?; + let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?; + super::face_models::set_cpu_only(&dir, cpu)?; + let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone()); + #[cfg(not(target_arch = "wasm32"))] + let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone()); + #[cfg(target_arch = "wasm32")] + let embedder: Option> = None; + if cpu { + for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] { + if let Some(net) = net { + net.detach(); + } + set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() }); + } + } else { + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(d) = detector { + offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true)); + } + if let Some(e) = embedder { + offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string())); + } + } + } + } + Ok(gpu_json(s)) +} + +/// What `faces.gpu` and `faces.models.list` say about the GPU. +pub(crate) fn gpu_json(s: &Session) -> Value { + json!({ + "cpuOnly": cpu_only(s), + "detection": gpu_state("detection"), + "recognition": gpu_state("recognition"), + }) +} + fn face_json(f: &Face) -> Value { json!({ "rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1}, @@ -132,7 +257,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) => { @@ -173,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result { } pub fn specs() -> Vec { - vec![cmd!( - "faces.detect", - "Detect Faces", - ["Photo"], - None, - "{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched", - has_selection, - detect - )] + vec![ + cmd!( + "faces.detect", + "Detect Faces", + ["Photo"], + None, + "{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched", + has_selection, + detect + ), + cmd!( + query "faces.gpu", + "Face Models on the GPU", + [], + None, + "{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again", + always, + gpu + ), + ] } #[cfg(test)] diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs index 6033c4dcc3..1ea39d04c1 100644 --- a/crates/engine/src/cmd/face_models.rs +++ b/crates/engine/src/cmd/face_models.rs @@ -27,6 +27,9 @@ pub(super) struct FaceSettings { pub(super) enabled: bool, /// The installed recogniser in use. pub(super) embedder: Option, + /// Keep every face model on the CPU, even when the GPU would be faster. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub(super) cpu_only: bool, } fn fail(what: &str, e: impl std::fmt::Display) -> EngineError { @@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { }) } +/// Keep the face models on the CPU (or let them use the GPU again). +pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> { + let mut st = read_settings(dir); + st.cpu_only = on; + write_settings(dir, &st) +} + fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> { std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?; write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?) @@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result { "dir": dir.as_ref().map(|d| d.display().to_string()), "enabled": settings.enabled, "embedder": settings.embedder, + // where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it + "gpu": super::face_detect::gpu_json(s), // Native builds always have the pure-Rust runner; model files/background scanning remain native-only. "runtime": cfg!(not(target_arch = "wasm32")), "models": models, diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec3..228a1ce3dd 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -1,10 +1,11 @@ //! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed //! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every //! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is -//! what teaches the suggestions). +//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two +//! people when asked. //! //! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is -//! catalog-only (no sidecar is written) and `faces.setName` is undoable. +//! catalog-only (no sidecar is written) and naming is undoable. use lightcraft_catalog::Op; use serde_json::{Value, json}; @@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id.0, "index": index, "name": name})) } +/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo +/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused +/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing. +fn rename_person(s: &mut Session, p: &Value) -> Result { + const C: &str = "faces.renamePerson"; + let from = match p.get("from") { + Some(Value::String(v)) => { + let t = v.trim(); + if t.is_empty() { + return Err(bad(C, "missing `from`")); + } + t.to_string() + } + Some(_) => return Err(bad(C, "`from` must be text")), + None => return Err(bad(C, "missing `from`")), + }; + let to = match p.get("to") { + Some(Value::String(v)) => { + let t = v.trim(); + if t.is_empty() { + return Err(bad(C, "`to` must be a name")); + } + if t.chars().count() > 200 || t.chars().any(char::is_control) { + return Err(bad(C, "a name is at most 200 characters, without control characters")); + } + t.to_string() + } + Some(_) => return Err(bad(C, "`to` must be text")), + None => return Err(bad(C, "missing `to`")), + }; + let merge = super::bool_or(p, "merge", false); + let from_key = from.to_lowercase(); + let to_key = to.to_lowercase(); + let mut matched = 0usize; + let mut other = false; + let mut faces = 0usize; + let mut ops = Vec::new(); + let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect(); + for id in ids { + let Some(ph) = s.catalog.photo(id) else { continue }; + let mut indexes = Vec::new(); + for (i, r) in ph.meta.regions.iter().enumerate() { + if r.kind != lightcraft_meta::RegionKind::Face { + continue; + } + let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = n.to_lowercase(); + if key == from_key { + matched += 1; + if r.name.as_deref() != Some(to.as_str()) { + indexes.push(i); + } + } else if key == to_key { + other = true; + } + } + if indexes.is_empty() { + continue; + } + let mut meta = ph.meta.clone(); + for i in indexes { + if let Some(region) = meta.regions.get_mut(i) { + region.name = Some(to.clone()); + faces += 1; + } + } + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + if matched == 0 { + return Err(bad(C, format!("no person named {from}"))); + } + if faces == 0 { + return Err(bad(C, format!("{to} is already their name"))); + } + if other && !merge { + return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them"))); + } + let photos = ops.len(); + s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?; + s.skip_auto_write = true; + if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) { + s.filter.person = Some(to.clone()); + } + Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other})) +} + /// Most faces `faces.unnamed` lists. const MAX_UNNAMED: usize = 3000; @@ -234,6 +321,8 @@ mod imp { return Ok(e.clone()); } let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?); + let net = embedder.clone(); + super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string())); let dim = match installed.manifest.output { lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize, _ => return Err(bad(cmd, "the chosen model is not a recognition model")), @@ -275,7 +364,7 @@ mod imp { } let job = s.preview_job(id, EDGE, EDGE, false, &DevelopSettings::default())?; let preview = s.scan_preview_of(&photo); - Some(Prepared { tag, epoch: s.faces.epoch, id, job, preview, todo, detect, detector }) + Some(Prepared { tag, epoch: s.faces.epoch, id, content: crate::media::content_key(&photo), job, preview, todo, detect, detector }) } /// The faces the detector found in a photo that had none become its face regions: unnamed, marked as detected (so a @@ -299,13 +388,16 @@ mod imp { } } - /// Take a finished photo into the index (ignored when it was made for another model or another library). Returns - /// (embedded, failed). + /// Take a finished photo into the index (ignored when it was made for another model or another library, or from + /// a file the photo no longer has: issue #1524). Returns (embedded, failed). fn ingest(s: &mut Session, d: Done) -> (usize, usize) { s.faces.in_flight.remove(&d.id); if d.epoch != s.faces.epoch || d.tag != s.faces.index.tag { return (0, 0); } + if s.catalog.photo(d.id).is_none_or(|p| crate::media::content_key(p) != d.content) { + return (0, 0); + } if d.detect_wanted { match &d.found { Some(found) => { @@ -582,6 +674,9 @@ mod imp { if let Some(w) = &s.faces.worker { w.allow(target, pace); } + // the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders + let (runs, share) = crate::faces_worker::gpu_pace(pace); + lightcraft_gpu::faces::set_pace(runs, share); while target > 0 && s.faces.in_flight.len() < target + PREFETCH { let Some(id) = s.faces.queue.pop() else { break }; let Some(job) = prepare(s, id, true) else { continue }; @@ -751,7 +846,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); @@ -830,7 +925,8 @@ mod imp { } fn finished(s: &Session, id: PhotoId, found: Option>, results: Vec<(Rect, Option>, Rect)>) -> Done { - Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, results, detect_wanted: true, found } + let content = s.catalog.photo(id).map(|p| crate::media::content_key(p)).unwrap_or_default(); + Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, content, results, detect_wanted: true, found } } #[test] @@ -915,6 +1011,42 @@ mod imp { let _ = std::fs::remove_dir_all(&d); } + /// A Reload that finds the file replaced (an outside edit) gives the photo other pixels: what a worker made from + /// the old file is dropped when it comes back, the faces already embedded from it are forgotten and the photo is + /// searched again (issue #1524). + #[test] + fn faces_made_from_a_replaced_file_are_dropped() { + let (mut s, d) = loaded("replaced"); + let (a, b) = two_photos(&s); + let rect = region(0.2, None).rect; + s.faces.index.insert(a.0, &rect, unit(&[(0, 1.0)])); + s.faces.scanned.insert(a.0); + s.faces.index.insert(b.0, &rect, unit(&[(1, 1.0)])); + s.faces.scanned.insert(b.0); + let old = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(2, 1.0)])), rect)]); + let (width, height) = s.catalog.photo(a).map(|p| (p.width, p.height)).unwrap(); + let replace = Op::SetContent { + id: a, + width, + height, + file_size: 1234, + content_hash: Some("edited".into()), + preview_only: None, + preview_checked: String::new(), + }; + s.commit("Reload Changed Files", replace).unwrap(); + assert!(s.faces.index.get(a.0, &rect).is_none() && !s.faces.scanned.contains(a.0), "the old file's faces are forgotten"); + assert!(needs_search(&s, s.catalog.photo(a).unwrap()), "and the photo is searched again"); + assert!(s.faces.index.get(b.0, &rect).is_some() && s.faces.scanned.contains(b.0), "other photos keep theirs"); + assert_eq!(ingest(&mut s, old), (0, 0), "a result made from the old file is dropped"); + assert!(s.catalog.photo(a).unwrap().meta.regions.is_empty() && !s.faces.scanned.contains(a.0)); + // what is made from the new file is taken in + let new = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(3, 1.0)])), rect)]); + assert_eq!(ingest(&mut s, new), (1, 0)); + assert!(s.faces.scanned.contains(a.0) && s.faces.index.get(a.0, &rect).is_some()); + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn the_unnamed_faces_that_look_like_a_person_come_best_first() { let (mut s, d) = loaded("similar"); @@ -1047,5 +1179,14 @@ pub fn specs() -> Vec { super::always, set_name ), + cmd!( + "faces.renamePerson", + "Rename Person", + [], + None, + "{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched", + super::always, + rename_person + ), ] } diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs new file mode 100644 index 0000000000..efe1d1b7f6 --- /dev/null +++ b/crates/engine/src/cmd/focus.rs @@ -0,0 +1,50 @@ +//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the +//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`. + +use serde_json::Value; + +use super::{CommandSpec, cmd}; +use crate::{Result, Session}; + +#[cfg(not(target_arch = "wasm32"))] +fn read_points(s: &mut Session, p: &Value) -> Result { + crate::focus_scan::read_points(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn read_points(_: &mut Session, _: &Value) -> Result { + Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn pump(s: &mut Session, p: &Value) -> Result { + crate::focus_scan::pump(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn pump(_: &mut Session, _: &Value) -> Result { + Ok(serde_json::json!({"active": false})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "photo.readFocusPoints", + "Read Focus Points", + ["Photo"], + None, + "{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)", + super::always, + read_points + ), + cmd!( + query "focus.pump", + "Read Focus Points in the Background", + [], + None, + "{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at", + super::always, + pump + ), + ] +} diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index ddbe6b13af..085183d587 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 b2c7e90f0d..fe09a92f97 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 3a5575ea81..7b0e1e5620 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -167,9 +167,22 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec) -> Re Ok((kept, skipped)) } +/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep +/// showing `i/n` (issue #1493). +fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result { + let mut ops: Vec = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); + let n = ops.len(); + if n == 0 { + return Ok(0); + } + ops.extend(s.catalog.remove_from_stacks_ops(ids)); + s.commit(label, Op::Batch { ops })?; + Ok(n) +} + /// 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 { @@ -181,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 { @@ -196,6 +209,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result pick -> reject -> unflagged, +/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step. +fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result { + let mut ops = Vec::new(); + 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 { + ops.push(Op::SetFlag { id, flag }); + } + } + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + advance_if(s, p); + Ok(json!({"changed": n})) +} + fn advance_if(s: &mut Session, p: &Value) { if bool_or(p, "advance", false) { let _ = step(s, 1); @@ -226,12 +258,16 @@ 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) { let mut d = (*d).clone(); d.orientation = d.orientation.rotated(cw); + // the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo) + let turn = lightcraft_geom::Orientation::Normal.rotated(cw); + d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect); + d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w)); ops.extend(s.develop_op(id, d, "Rotate")); } } @@ -241,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) { @@ -362,11 +398,8 @@ pub fn specs() -> Vec { } other => return Err(bad("library.source", format!("unknown source `{other}`"))), }; - let vis = s.visible_cloned(); - if s.selection.active.is_none_or(|a| !vis.contains(&a)) { - s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); - } - Ok(json!({"count": vis.len()})) + s.keep_active_in_view(); + Ok(json!({"count": s.visible().len()})) } ), cmd!( @@ -436,8 +469,7 @@ pub fn specs() -> Vec { if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) { return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); } - let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); - s.commit("Remove Folder from Library", Op::Batch { ops })?; + mark_deleted(s, &ids, "Remove Folder from Library")?; if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { s.filter.library_folder = None; } @@ -510,6 +542,44 @@ pub fn specs() -> Vec { ok() } ), + cmd!( + "library.refresh", + "Refresh View", + ["View"], + None, + "{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed", + always, + |s, _| { + s.refresh_view(); + ok() + } + ), + cmd!( + "library.holdView", + "Hold View Until Refreshed", + ["View"], + None, + "{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing", + always, + |s, p| { + let on = match p.get("on") { + None | Some(Value::Null) => !s.hold_view, + Some(Value::Bool(on)) => *on, + Some(_) => return Err(bad("library.holdView", "on must be true or false")), + }; + if on != s.hold_view { + let was = s.hold_view; + s.hold_view = on; + if let Err(e) = s.save_prefs() { + // not kept: not changed either, so asking again does what was asked + s.hold_view = was; + return Err(e); + } + s.refresh_view(); + } + Ok(json!({"hold": s.hold_view})) + } + ), cmd!( "library.shuffle", "Reshuffle", @@ -687,6 +757,40 @@ pub fn specs() -> Vec { advance_if(s, p); Ok(v) }), + // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) + cmd!( + "photo.cycleFlag", + "Cycle Flag", + ["Photo", "Set Flag"], + Some("`"), + "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged", + has_selection, + |s, p| { + change_flag(s, p, "Cycle Flag", |f| match f { + Flag::None => Flag::Pick, + Flag::Pick => Flag::Reject, + Flag::Reject => Flag::None, + }) + } + ), + cmd!( + "photo.increaseFlag", + "Increase Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Up"), + "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)", + has_selection, + |s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) } + ), + cmd!( + "photo.decreaseFlag", + "Decrease Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Down"), + "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)", + has_selection, + |s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) } + ), cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| { for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick })) }), @@ -779,9 +883,10 @@ 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 v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; + let targets = s.targets(p)?; + let n = mark_deleted(s, &targets, "Delete")?; select_after_deletion(s, &before); - Ok(v) + Ok(json!({"changed": n})) }), cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { id, @@ -807,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); @@ -824,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, @@ -922,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( @@ -1163,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")); @@ -1171,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); } } @@ -1190,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()) { @@ -1203,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()); @@ -1334,17 +1443,39 @@ pub fn specs() -> Vec { Ok(result) } ), - cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| { - let id = album_param(p, "id", "album.removePhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); - 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")); + cmd!( + "album.removePhotos", + "Remove from Album", + ["Photo"], + None, + "{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}", + has_selection, + |s, p| { + let id = match p.get("id") { + Some(_) => album_param(p, "id", "album.removePhotos")?, + None => match s.source { + LibrarySource::Album(a) => a, + _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), + }, + }; + 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")); + } + if al.folder { + 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 { + s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; + } + Ok(json!({"removed": removed, "album": name})) } - let photos: Vec = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); - s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; - ok() - }), + ), cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| { let id = album_param(p, "id", "album.setCover")?; let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active()); @@ -1623,3 +1754,7 @@ mod gps_tests { #[cfg(test)] #[path = "library/tests_relative_ratings.rs"] mod relative_rating_tests; + +#[cfg(test)] +#[path = "library/tests_flag_cycle.rs"] +mod flag_cycle_tests; diff --git a/crates/engine/src/cmd/library/tests_flag_cycle.rs b/crates/engine/src/cmd/library/tests_flag_cycle.rs new file mode 100644 index 0000000000..6a476dc1de --- /dev/null +++ b/crates/engine/src/cmd/library/tests_flag_cycle.rs @@ -0,0 +1,100 @@ +//! `photo.cycleFlag` / `photo.increaseFlag` / `photo.decreaseFlag` through the real engine. +use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Result, Selection, Session}; + +fn session() -> Result { + let mut s = Session::new(); + for (id, flag) in [(1, Flag::None), (2, Flag::Pick), (3, Flag::Reject), (4, Flag::None)] { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-flag-cycle/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-13", + ); + photo.flag = flag; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?; + s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?; + Ok(s) +} + +fn flags(s: &Session) -> Vec { + (1..=4).map(|id| s.catalog.photo(PhotoId(id)).unwrap().flag).collect() +} + +#[test] +fn cycle_steps_unflagged_pick_reject_and_wraps() { + let mut s = session().unwrap(); + s.execute("library.select", &json!({"ids": [1]})).unwrap(); + let seen: Vec = (0..4) + .map(|_| { + s.execute("photo.cycleFlag", &json!({})).unwrap(); + flags(&s)[0] + }) + .collect(); + assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None, Flag::Pick]); +} + +#[test] +fn cycle_uses_each_photos_own_flag_in_one_undo_step() { + let mut s = session().unwrap(); + let undo = s.undo.len(); + assert_eq!(s.execute("photo.cycleFlag", &json!({})).unwrap()["changed"], 3); + assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.active(), Some(PhotoId(2))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::None]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]); +} + +#[test] +fn explicit_ids_active_fallback_advance_and_errors() { + let mut s = session().unwrap(); + s.execute("photo.cycleFlag", &json!({"ids": [4]})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::Pick]); + s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) }; + s.execute("photo.cycleFlag", &json!({})).unwrap(); + assert_eq!(flags(&s)[1], Flag::Reject); + s.execute("photo.cycleFlag", &json!({"advance": true})).unwrap(); + assert_eq!(flags(&s)[1], Flag::None); + assert_eq!(s.active(), Some(PhotoId(3))); + let undo = s.undo.len(); + assert!(s.execute("photo.cycleFlag", &json!({"ids": [1, 999]})).is_err()); + assert_eq!(flags(&s)[0], Flag::None); + assert_eq!(s.undo.len(), undo); + s.selection = Selection::default(); + assert!(s.execute("photo.cycleFlag", &json!({})).is_err()); + assert_eq!(s.undo.len(), undo); +} + +#[test] +fn increase_and_decrease_clamp_without_wrapping() { + let mut s = session().unwrap(); + assert_eq!(s.execute("photo.increaseFlag", &json!({})).unwrap()["changed"], 2); + assert_eq!(flags(&s), [Flag::Pick, Flag::Pick, Flag::None, Flag::None]); + assert_eq!(s.execute("photo.increaseFlag", &json!({"ids": [2]})).unwrap()["changed"], 0); + s.execute("photo.decreaseFlag", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::None, Flag::Reject, Flag::None]); + s.execute("photo.decreaseFlag", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::Reject, Flag::Reject, Flag::Reject, Flag::None]); + let undo = s.undo.len(); + assert_eq!(s.execute("photo.decreaseFlag", &json!({})).unwrap()["changed"], 0); + assert_eq!(s.undo.len(), undo, "no change, no history step"); +} + +#[test] +fn registered_with_classic_shortcuts() { + let specs = crate::command_specs(); + let key = |id: &str| specs.iter().find(|c| c.id == id).and_then(|c| c.shortcut); + assert_eq!(key("photo.cycleFlag"), Some("`")); + assert_eq!(key("photo.increaseFlag"), Some("Cmd+Up")); + assert_eq!(key("photo.decreaseFlag"), Some("Cmd+Down")); +} diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index 349e66ea80..f03809b621 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 d06271525d..66ddfe9fbc 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/mask_effect_files.rs b/crates/engine/src/cmd/mask_effect_files.rs new file mode 100644 index 0000000000..e1e099bc8a --- /dev/null +++ b/crates/engine/src/cmd/mask_effect_files.rs @@ -0,0 +1,681 @@ +//! Mask effect preset files: Lightroom Classic local adjustment presets (`.lrtemplate`, Lua, +//! `type = "LocalizedAdjustmentPreset"`), Camera Raw local correction presets (`.xmp` with +//! `crs:Local*` fields, from its `LocalCorrections` folder), our `.lceffect` JSON, and `.zip` +//! bundles of these. Folders, and Lightroom users' "divider" presets (`10 ----- DETAIL ----- 10`, +//! empty presets that head a numbered section of the menu), become groups. +//! +//! Read from the files' public structure and black-box observation; settings we have no slider +//! for are reported and kept with the effect (an export carries them). + +use lightcraft_develop::LocalAdjustments; +use serde_json::{Map, Value, json}; + +use super::mask_effects::{MaskEffect, upsert}; +use super::{bad, str_param}; +use crate::preset_import::{Lua, group_from_dir, parse_lua, read_zip}; +use crate::{Result, Session}; + +/// File extension of exported effect presets (JSON). +pub const EFFECT_EXT: &str = "lceffect"; +/// `format` tag of an exported effect-preset file. +const FILE_FORMAT: &str = "lightcraft.maskEffects"; +/// The Lua `type` of a Lightroom local adjustment preset. +const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset"; +/// Larger files aren't presets (or bundles of them). +const MAX_FILE: u64 = 64 << 20; +/// How deep nested Lua tables are kept as JSON. +const MAX_DEPTH: usize = 16; + +/// An effect read from a file, with the settings it couldn't keep and where it came from. +#[derive(Clone, Debug)] +pub struct Found { + pub effect: MaskEffect, + pub unmapped: Vec, + pub file: String, + /// Effects of one folder (or folder inside a bundle) share this key: divider presets group + /// the effects next to them. + pub dir: String, + /// The group the folder gives (when the file names none). + pub dir_group: Option, + /// The file named its group (`.lceffect`, `crs:Group`): kept. + pub has_group: bool, +} + +/// A readable name for a Lightroom local setting we can't carry over. +fn setting_label(key: &str) -> String { + let k = key.trim_start_matches("Local"); + let lower = k.to_ascii_lowercase(); + match lower.as_str() { + "grain" => "Grain".into(), + "toningluminance" => "Color luminance".into(), + "pointcolors" => "Point Color".into(), + "refinesaturation" => "Refine Saturation".into(), + "curverefinesaturation" => "Refine Saturation".into(), + "maincurve" | "redcurve" | "greencurve" | "bluecurve" => "Curve".into(), + _ => k.to_string(), + } +} + +/// A Lua value as JSON (tables nested at most [`MAX_DEPTH`] deep). +fn lua_json(v: &Lua, depth: usize) -> Value { + match v { + Lua::Nil => Value::Null, + Lua::Bool(b) => json!(b), + Lua::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null), + Lua::Str(s) => json!(s), + Lua::Table(..) if depth >= MAX_DEPTH => Value::Null, + Lua::Table(items, fields) if fields.is_empty() => Value::Array(items.iter().map(|x| lua_json(x, depth + 1)).collect()), + Lua::Table(_, fields) => Value::Object(fields.iter().map(|(k, x)| (k.clone(), lua_json(x, depth + 1))).collect()), + } +} + +/// Does a setting value do anything (a non-zero number, a non-empty table, …)? +fn meaningful(v: &Value) -> bool { + match v { + Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().is_some_and(|x| x.abs() > 1e-9), + Value::String(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Object(o) => !o.is_empty(), + } +} + +/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)? +pub fn is_lr_local_template(text: &str) -> bool { + parse_lua(text.trim_start_matches('\u{feff}')) + .ok() + .and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE)) + .unwrap_or(false) +} + +/// `$$$/Path/Key=Default` (a localisable string) → `Default`. +fn delocalize(t: &str) -> &str { + t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d) +} + +/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the +/// settings we have no equivalent for (e.g. `Grain`, `Point Color`), which the effect keeps in +/// `extra`. +pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec), String> { + let root = parse_lua(text.trim_start_matches('\u{feff}'))?; + if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) { + return Err("not a local adjustment preset".into()); + } + let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) }; + // `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case + let crs = |k: &str| { + let mut c = k.chars(); + c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default() + }; + let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None }); + let values = crate::crs_masks::local_values(num); + let mut extra = Map::new(); + for (k, v) in fields { + if crate::crs_masks::is_local_field(&crs(k)) { + continue; + } + let j = lua_json(v, 0); + if meaningful(&j) { + extra.insert(k.clone(), j); + } + } + let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty()); + let name = title.map(|t| delocalize(t).trim().to_string()).filter(|t| !t.is_empty()).unwrap_or_else(|| stem.to_string()); + build(name, values, extra) +} + +fn build(name: String, values: Map, extra: Map) -> std::result::Result<(MaskEffect, Vec), String> { + let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?; + let mut unmapped: Vec = extra.keys().map(|k| setting_label(k)).collect(); + unmapped.sort(); + unmapped.dedup(); + let effect = MaskEffect { name, adjust, extra, ..Default::default() }.normalized(); + Ok((effect, unmapped)) +} + +/// The local slider fields of a Camera Raw local correction preset: its top-level `crs:Local*` +/// properties, or (a preset typed `Local` without them) its first correction's. +fn xmp_local_fields(d: &lightcraft_meta::XmpData) -> Option> { + let mut out = Map::new(); + for (k, v) in &d.properties { + if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local") && !n.contains('/')) + && let Some(x) = v.first() + { + out.insert(name.to_string(), json!(x.trim())); + } + } + if !out.is_empty() { + return Some(out); + } + let typed_local = d.properties.get("crs:PresetType").and_then(|v| v.first()).is_some_and(|t| t.trim().eq_ignore_ascii_case("local")); + if !typed_local { + return None; + } + let correction = crate::crs_masks::CONTAINERS.iter().find_map(|c| d.values.get(*c).and_then(|l| l.items().first()))?; + let lightcraft_meta::XmpValue::Struct(m) = correction else { return None }; + for (k, v) in m { + if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local")) + && let Some(x) = v.text() + { + out.insert(name.to_string(), json!(x)); + } + } + Some(out) +} + +/// Is `text` a Camera Raw local correction preset (XMP)? +pub fn is_local_xmp(text: &str) -> bool { + lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).ok().and_then(|d| xmp_local_fields(&d)).is_some() +} + +/// A Camera Raw local correction preset (XMP) → an effect named after `crs:Name` (else `stem`), +/// in `crs:Group` if it names one. +pub fn parse_local_xmp(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec), String> { + let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?; + let fields = xmp_local_fields(&d).ok_or("not a local correction preset (no crs:Local… settings)")?; + let num = |v: &Value| v.as_str().map(|s| s.strip_prefix('+').unwrap_or(s)).and_then(|s| s.parse::().ok()).filter(|x| x.is_finite()); + let values = crate::crs_masks::local_values(|k| fields.get(k).and_then(num)); + let mut extra = Map::new(); + for (k, v) in &fields { + if crate::crs_masks::is_local_field(k) { + continue; + } + let v = num(v).map_or_else(|| v.clone(), |x| json!(x)); + if meaningful(&v) { + extra.insert(k.clone(), v); + } + } + let first = |k: &str| d.properties.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let name = first("crs:Name").unwrap_or_else(|| stem.to_string()); + let (mut e, unmapped) = build(name, values, extra)?; + e.group = first("crs:Group").unwrap_or_default(); + Ok((e, unmapped)) +} + +/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect. +pub fn parse_file(bytes: &[u8]) -> std::result::Result, String> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?; + let items = match v { + Value::Object(mut o) if o.contains_key("effects") => o.remove("effects").unwrap_or_default(), + v @ Value::Array(_) => v, + v @ Value::Object(_) => Value::Array(vec![v]), + _ => return Err("not an effect preset file".into()), + }; + let list: Vec = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?; + let list: Vec = list.into_iter().filter(|e| !e.name.trim().is_empty()).map(MaskEffect::normalized).collect(); + if list.is_empty() { + return Err("no effect presets in the file".into()); + } + Ok(list) +} + +fn ext_of(name: &str) -> String { + std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() +} + +fn stem_of(name: &str) -> String { + std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Effect".into()) +} + +type Parsed = std::result::Result)>, String>; + +/// The effects in file `name` (`bytes`) if it is a local preset (`.lrtemplate` of the local type, +/// a local correction `.xmp`, an `.lceffect`); `None` for any other file (a develop preset…). +pub fn parse_bytes(name: &str, bytes: &[u8]) -> Option { + let stem = stem_of(name); + match ext_of(name).as_str() { + "lrtemplate" => { + let text = String::from_utf8_lossy(bytes); + is_lr_local_template(&text).then(|| parse_lr_local_template(&text, &stem).map(|e| vec![e])) + } + "xmp" => { + let text = String::from_utf8_lossy(bytes); + is_local_xmp(&text).then(|| parse_local_xmp(&text, &stem).map(|e| vec![e])) + } + EFFECT_EXT => Some(parse_file(bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect())), + _ => None, + } +} + +/// The group effects in folder `dir` (relative to the imported folder) go to. +pub fn group_for_dir(dir: &str) -> Option { + effect_group_from_dir(dir) +} + +/// Folder names that only say "local presets" (`Local Adjustment Presets`, `LocalCorrections`…) +/// give no group. +fn effect_group_from_dir(dir: &str) -> Option { + const FILLER: &[&str] = &[ + "local", + "adjustment", + "adjustments", + "localcorrections", + "corrections", + "correction", + "brush", + "brushes", + "mask", + "masks", + "effect", + "effects", + ]; + let generic = |part: &str| { + let lower = part.to_ascii_lowercase(); + let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + words.iter().any(|w| FILLER.contains(w)) + && words.iter().all(|w| FILLER.contains(w) || matches!(*w, "presets" | "preset" | "lightroom" | "camera" | "raw" | "classic")) + }; + let kept: Vec<&str> = dir.split(['/', '\\']).filter(|p| !p.is_empty() && !generic(p)).collect(); + group_from_dir(&kept.join("/")) +} + +/// Effects found in `bytes` of file `file` (a `.zip` is opened: the local presets inside). +/// `dir_group`: the group the file's folder gives. Errors are `(file, message)`. +pub fn found_in(file: &str, bytes: &[u8], dir_group: Option, out: &mut Vec, failed: &mut Vec<(String, String)>) -> bool { + let dir = std::path::Path::new(file).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(); + if ext_of(file) == "zip" { + let Ok(entries) = read_zip(bytes) else { return false }; + let zip_group = effect_group_from_dir(&stem_of(file)).or(dir_group); + let mut any = false; + for (inner, data) in entries { + let inner_dir = inner.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default(); + let group = effect_group_from_dir(&inner_dir).or_else(|| zip_group.clone()); + let label = format!("{file}/{inner}"); + match parse_bytes(&inner, &data) { + Some(Ok(list)) => { + any = true; + push(out, list, &label, format!("{file}/{inner_dir}"), group, &inner); + } + Some(Err(e)) => { + any = true; + failed.push((label, e)); + } + None => {} + } + } + return any; + } + match parse_bytes(file, bytes) { + Some(Ok(list)) => { + push(out, list, file, dir, dir_group, file); + true + } + Some(Err(e)) => { + failed.push((file.to_string(), e)); + true + } + None => false, + } +} + +fn push(out: &mut Vec, list: Vec<(MaskEffect, Vec)>, file: &str, dir: String, dir_group: Option, name: &str) { + let named = ext_of(name) == EFFECT_EXT; + for (effect, unmapped) in list { + let has_group = named || !effect.group.is_empty(); + out.push(Found { effect, unmapped, file: file.to_string(), dir: dir.clone(), dir_group: dir_group.clone(), has_group }); + } +} + +/// The number a preset name starts with (`24a-Eye` → 24). +fn leading_number(name: &str) -> Option { + let digits: String = name.trim().chars().take_while(char::is_ascii_digit).take(18).collect(); + digits.parse().ok() +} + +/// The section title of a divider preset — an effect that sets nothing, named like +/// `10 ----- DETAIL ENHANCERS ----- 10` (a run of at least three `-`, `=`, `_`, `*`, `#`, `~`, `—`). +pub fn divider_title(e: &MaskEffect) -> Option { + if !e.is_empty() { + return None; + } + let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.']; + let chars: Vec = e.name.chars().collect(); + let has_run = chars.windows(3).any(|w| marks.contains(&w[0]) && w[0] == w[1] && w[1] == w[2]); + if !has_run { + return None; + } + let title: String = e.name.chars().map(|c| if marks.contains(&c) { ' ' } else { c }).collect(); + let title = title.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c.is_whitespace()); + let title = title.trim_end_matches(|c: char| c.is_ascii_digit() || c.is_whitespace()); + let title = title.split_whitespace().collect::>().join(" "); + if !title.chars().any(char::is_alphanumeric) { + return None; + } + // "DETAIL ENHANCERS" → "Detail Enhancers" + let shouting = title.chars().any(char::is_alphabetic) && !title.chars().any(char::is_lowercase); + Some(if shouting { + title + .split(' ') + .map(|w| { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::()).unwrap_or_default() + }) + .collect::>() + .join(" ") + } else { + title + }) +} + +/// Give `found` their groups; returns the divider presets (which become groups, not effects) as +/// `(found, group)`. A numbered effect goes to the divider with the largest number not above its +/// own (`12 Sky` under `10 --- DETAIL --- 10`); others to their folder's group. +pub fn assign_groups(found: Vec, group: Option<&str>) -> (Vec, Vec<(Found, String)>) { + let (dividers, mut effects): (Vec<(Found, Option)>, Vec<(Found, Option)>) = + found.into_iter().map(|f| (divider_title(&f.effect).filter(|_| !f.has_group), f)).map(|(t, f)| (f, t)).partition(|(_, t)| t.is_some()); + let dividers: Vec<(Found, String)> = dividers.into_iter().filter_map(|(f, t)| t.map(|t| (f, t))).collect(); + for (f, _) in &mut effects { + if let Some(g) = group { + f.effect.group = g.to_string(); + continue; + } + if f.has_group { + continue; + } + let section = leading_number(&f.effect.name).and_then(|n| { + dividers + .iter() + .filter(|(d, _)| d.dir == f.dir) + .filter_map(|(d, t)| leading_number(&d.effect.name).filter(|m| *m <= n).map(|m| (m, t))) + .max_by_key(|(m, _)| *m) + .map(|(_, t)| t.clone()) + }); + f.effect.group = section.or_else(|| f.dir_group.clone()).unwrap_or_default(); + } + (effects.into_iter().map(|(f, _)| f).collect(), dividers) +} + +/// Add the found effects (grouped; same-named ones replaced; effects left from an earlier import +/// of a divider dropped) and save → the report rows `(imported, dividers)`. +pub fn add_found(s: &mut Session, found: Vec, group: Option<&str>, dry: bool) -> Result<(Vec, Vec)> { + let (effects, dividers) = assign_groups(found, group); + let rows: Vec = + effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect(); + let div_rows: Vec = dividers.iter().map(|(f, g)| json!({"name": f.effect.name, "group": g, "file": f.file})).collect(); + if dry || (effects.is_empty() && dividers.is_empty()) { + return Ok((rows, div_rows)); + } + s.mask_effects.retain(|e| !(e.is_empty() && dividers.iter().any(|(d, _)| d.effect.name.eq_ignore_ascii_case(&e.name)))); + for f in effects { + upsert(s, f.effect); + } + s.save_prefs()?; + Ok((rows, div_rows)) +} + +/// The files under `paths` an effect import reads (folders walked, at most 8 levels), each with +/// the group its folder gives and whether it was named (not found in a folder). +fn effect_files(paths: &[String]) -> Vec<(String, Option, bool)> { + fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option, bool)>) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + if depth < 8 { + walk(&p, base, depth + 1, out); + } + } else if p.extension().is_some_and(|e| ["lrtemplate", EFFECT_EXT, "xmp", "zip"].iter().any(|x| e.eq_ignore_ascii_case(x))) { + let rel = p.parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string()); + out.push((p.to_string_lossy().to_string(), rel.and_then(|d| effect_group_from_dir(&d)), false)); + } + } + } + let mut out = Vec::new(); + for x in paths { + let p = std::path::Path::new(x); + if p.is_dir() { + walk(p, p.parent().unwrap_or(std::path::Path::new("")), 0, &mut out); + } else { + out.push((x.clone(), None, true)); + } + } + out +} + +/// Read a file of at most [`MAX_FILE`] bytes. +pub fn read_capped(path: &str) -> std::result::Result, String> { + let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + if len > MAX_FILE { + return Err(format!("too large for a preset file ({} MB)", len >> 20)); + } + std::fs::read(path).map_err(|e| e.to_string()) +} + +pub fn import_effects(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.importEffects"; + let mut paths: Vec = + p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + if let Some(x) = str_param(p, "path") { + paths.push(x.to_string()); + } + if paths.is_empty() { + return Err(bad(C, "missing `path` / `paths`")); + } + let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()); + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let (mut found, mut failed) = (Vec::new(), Vec::new()); + for (f, dir_group, named) in effect_files(&paths) { + match read_capped(&f) { + Ok(bytes) => { + if !found_in(&f, &bytes, dir_group, &mut found, &mut failed) && named { + failed.push((f, "not a local adjustment preset".into())); + } + } + Err(e) => failed.push((f, e)), + } + } + let (imported, dividers) = add_found(s, found, group, dry)?; + Ok(json!({"imported": imported, "dividers": dividers, "failed": failed.into_iter().map(|(f, e)| json!([f, e])).collect::>()})) +} + +pub fn export_effects(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.exportEffects"; + let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let mut path = std::path::PathBuf::from(path); + if path.extension().is_none() { + path.set_extension(EFFECT_EXT); + } + let names: Option> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + let group = str_param(p, "group").map(str::trim); + let chosen: Vec<&MaskEffect> = s + .mask_effects + .iter() + .filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name)))) + .filter(|e| group.is_none_or(|g| g.eq_ignore_ascii_case(&e.group))) + .collect(); + if chosen.is_empty() { + return Err(bad(C, "no effect presets to export")); + } + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; + } + let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 2, "effects": chosen})) + .map_err(|e| crate::EngineError::Other(e.to_string()))?; + std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + // A synthetic template in the shape Lightroom writes (our own values). + const TEMPLATE: &str = r#"s = { + id = "00000000-0000-0000-0000-000000000001", + internalName = "Soft Glow", + title = "Soft Glow", + type = "LocalizedAdjustmentPreset", + value = { + clarity = 0.5, + clarity2012 = -0.25, + exposure = 0, + exposure2012 = 0.125, + grain = 0.25, + highlights2012 = -0.5, + luminanceNoise = 0.1, + refineSaturation = 0, + temperature = 0.2, + texture = -0.3, + toningHue = 240, + toningLuminance = 0.1, + toningSaturation = 0, + pointColors = { + { + HueRange = { LowerFull = 0.3, LowerNone = 0, UpperFull = 0.6, UpperNone = 1, }, + HueShift = -0.05, + RangeAmount = 0.5, + SrcHue = 0.8, + }, + }, + }, + version = 0, +}"#; + + #[test] + fn reads_a_lightroom_local_template() { + assert!(is_lr_local_template(TEMPLATE)); + let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap(); + assert_eq!(e.name, "Soft Glow"); + assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops"); + assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one"); + assert!((e.adjust.highlights + 50.0).abs() < 1e-9); + assert!((e.adjust.temp - 20.0).abs() < 1e-9); + assert!((e.adjust.texture + 30.0).abs() < 1e-9); + assert!((e.adjust.noise - 10.0).abs() < 1e-9); + assert!((e.adjust.amount - 100.0).abs() < 1e-9); + assert_eq!(unmapped, vec!["Color luminance".to_string(), "Grain".into(), "Point Color".into()]); + assert_eq!(e.extra.get("grain"), Some(&json!(0.25)), "kept for export"); + assert_eq!(e.extra["pointColors"][0]["HueRange"]["UpperFull"], json!(0.6)); + assert!(!e.extra.contains_key("refineSaturation"), "zero settings aren't kept"); + } + + #[test] + fn develop_templates_are_not_local_ones() { + let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#; + assert!(!is_lr_local_template(develop)); + assert!(parse_lr_local_template(develop, "x").is_err()); + assert!(parse_bytes("x.lrtemplate", develop.as_bytes()).is_none(), "left to the develop preset import"); + } + + #[test] + fn malformed_files_are_errors_not_panics() { + for bad in [ + "garbage {", + "", + "s = {", + r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#, + r#"s = { type = "LocalizedAdjustmentPreset" }"#, + r#"s = { type = "LocalizedAdjustmentPreset", value = { exposure2012 = "x", grain = {{{{}}}} } }"#, + "s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 1e400, contrast2012 = -1e400 } }", + "\u{feff}s = { type = \"LocalizedAdjustmentPreset\", title = \"\", value = {} }", + ] { + let _ = parse_lr_local_template(bad, "x"); + let _ = parse_bytes("x.lrtemplate", bad.as_bytes()); + } + // deeply nested tables + let deep = format!("s = {{ type = \"LocalizedAdjustmentPreset\", value = {{ pointColors = {}1{} }} }}", "{".repeat(200), "}".repeat(200)); + let _ = parse_lr_local_template(&deep, "x"); + let (e, _) = parse_lr_local_template("s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 9 } }", "Stem").unwrap(); + assert_eq!((e.name.as_str(), e.adjust.exposure), ("Stem", 4.0), "values clamp, the file name names it"); + for bad in ["", "", "\u{0}\u{1}"] { + assert!(parse_bytes("x.xmp", bad.as_bytes()).is_none_or(|r| r.is_err())); + } + for bad in ["", "3", "[]", "{\"effects\": 3}", "[{\"name\": 4}]", "{\"effects\": [{\"name\": \" \"}]}"] { + assert!(parse_file(bad.as_bytes()).is_err(), "{bad}"); + } + assert!(!found_in("x.zip", b"PK\x05\x06garbage", None, &mut Vec::new(), &mut Vec::new())); + } + + /// A Camera Raw local correction preset (our own values). + const ACR: &str = r#" + + Lift Shadows + Faces +"#; + + #[test] + fn reads_a_camera_raw_local_preset() { + assert!(is_local_xmp(ACR)); + let (e, unmapped) = parse_local_xmp(ACR, "file").unwrap(); + assert_eq!((e.name.as_str(), e.group.as_str()), ("Lift Shadows", "Faces")); + assert_eq!((e.adjust.exposure, e.adjust.shadows), (1.0, 40.0)); + assert_eq!(unmapped, vec!["Grain".to_string()]); + // a develop preset is not one + let develop = ACR + .replace( + "crs:LocalExposure2012=\"+0.25\" crs:LocalShadows2012=\"0.4\" crs:LocalGrain=\"0.2\" crs:LocalTexture=\"0\"", + "crs:Exposure2012=\"1\"", + ) + .replace("PresetType=\"Local\"", "PresetType=\"Normal\""); + assert!(!is_local_xmp(&develop)); + // a preset typed Local that keeps its sliders in a correction + let nested = r#" + + +"#; + let (e, _) = parse_local_xmp(nested, "Soft").unwrap(); + assert_eq!((e.name.as_str(), e.adjust.clarity), ("Soft", -50.0)); + } + + fn found(name: &str, exposure: f64, dir: &str) -> Found { + Found { + effect: MaskEffect { name: name.into(), adjust: LocalAdjustments { exposure, ..Default::default() }, ..Default::default() }, + unmapped: Vec::new(), + file: format!("{dir}/{name}.lrtemplate"), + dir: dir.into(), + dir_group: None, + has_group: false, + } + } + + #[test] + fn dividers_become_groups() { + let e = |n: &str| MaskEffect { name: n.into(), ..Default::default() }; + assert_eq!(divider_title(&e("10 ----- DETAIL ENHANCERS ----- 10")).as_deref(), Some("Detail Enhancers")); + assert_eq!(divider_title(&e("50--------MISC-----50")).as_deref(), Some("Misc")); + assert_eq!(divider_title(&e("=== Portraits ===")).as_deref(), Some("Portraits")); + assert_eq!(divider_title(&e("11_General_-_All_Purpose")), None, "no run of marks"); + assert_eq!(divider_title(&e("-----")), None, "no title"); + let busy = MaskEffect { adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..e("20 --- X --- 20") }; + assert_eq!(divider_title(&busy), None, "a divider sets nothing"); + + let list = vec![ + found("01 Burn", -1.0, "a"), + found("10 ----- DETAIL ENHANCERS ----- 10", 0.0, "a"), + found("12 Sky", 0.5, "a"), + found("20 ----- RETOUCHING ----- 20", 0.0, "a"), + found("24a-Eye-Cleaner", 0.1, "a"), + found("99 Late", 0.1, "a"), + found("Burn (Darken)", -0.5, "a"), + Found { dir_group: Some("Other".into()), ..found("15 Elsewhere", 0.2, "b") }, + ]; + let (effects, dividers) = assign_groups(list.clone(), None); + let groups: Vec<(&str, &str)> = effects.iter().map(|f| (f.effect.name.as_str(), f.effect.group.as_str())).collect(); + assert_eq!( + groups, + vec![ + ("01 Burn", ""), + ("12 Sky", "Detail Enhancers"), + ("24a-Eye-Cleaner", "Retouching"), + ("99 Late", "Retouching"), + ("Burn (Darken)", ""), + ("15 Elsewhere", "Other"), + ] + ); + assert_eq!(dividers.len(), 2); + let (effects, _) = assign_groups(list, Some("Mine")); + assert!(effects.iter().all(|f| f.effect.group == "Mine")); + } + + #[test] + fn folder_names_that_only_say_local_presets_give_no_group() { + assert_eq!(effect_group_from_dir("Local Adjustment Presets"), None); + assert_eq!(effect_group_from_dir("LocalCorrections"), None); + assert_eq!(effect_group_from_dir("Local Adjustment Presets/Portrait Brushes"), Some("Portrait Brushes".into())); + assert_eq!(effect_group_from_dir("Skin Kit"), Some("Skin Kit".into())); + } +} diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs new file mode 100644 index 0000000000..387909a6d2 --- /dev/null +++ b/crates/engine/src/cmd/mask_effects.rs @@ -0,0 +1,756 @@ +//! Mask effect presets: named sets of local adjustment sliders picked from the Masking panel's +//! Effect menu (Lightroom's local adjustment / brush presets), saved with the library preferences. +//! Grouped (folders in the menu), favourites, partial presets (only some sliders, with or without +//! Amount) and optional brush settings. Files: [`super::mask_effect_files`]. + +use std::cmp::Ordering; + +use lightcraft_catalog::PhotoId; +use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use super::mask_effect_files as files; +use super::{CommandSpec, always, bad, cmd, has_active, str_param}; +use crate::{Result, Session}; + +/// The local sliders an effect can set (`LocalAdjustments` field names; Amount aside). +pub const FIELDS: &[&str] = &[ + "temp", + "tint", + "exposure", + "contrast", + "highlights", + "shadows", + "whites", + "blacks", + "texture", + "clarity", + "dehaze", + "hue", + "saturation", + "sharpness", + "noise", + "moire", + "defringe", + "color_hue", + "color_sat", +]; + +/// Brush settings an effect can carry (Lightroom keeps these per brush, not in presets). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BrushSettings { + /// Radius as a fraction of the image's long edge. + pub size: f64, + /// 0..100. + pub feather: f64, + pub flow: f64, + pub density: f64, + pub auto_mask: bool, +} + +impl Default for BrushSettings { + fn default() -> Self { + Self { size: 0.04, feather: 50.0, flow: 100.0, density: 100.0, auto_mask: false } + } +} + +impl BrushSettings { + fn clamped(self) -> Self { + let c = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d }; + let d = Self::default(); + Self { + size: c(self.size, 0.002, 0.5, d.size), + feather: c(self.feather, 0.0, 100.0, d.feather), + flow: c(self.flow, 1.0, 100.0, d.flow), + density: c(self.density, 1.0, 100.0, d.density), + auto_mask: self.auto_mask, + } + } +} + +/// A named set of local adjustment sliders. Applying one sets the sliders it names (all of them +/// when `fields` is empty, as Lightroom's presets do: those it doesn't change go to 0); the mask's +/// Amount stays unless `with_amount`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct MaskEffect { + pub name: String, + pub adjust: LocalAdjustments, + /// The Effect menu folder ("" = none). + #[serde(skip_serializing_if = "String::is_empty")] + pub group: String, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub favorite: bool, + /// The sliders this effect sets ([`FIELDS`] names); empty = all of them. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, + /// Applying sets the mask's Amount to `adjust.amount` too. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub with_amount: bool, + /// Brush settings the brush takes when the effect is applied. + #[serde(skip_serializing_if = "Option::is_none")] + pub brush: Option, + /// Settings of an imported preset that we have no slider for (Lightroom key → value), kept so + /// an export carries them. + #[serde(skip_serializing_if = "Map::is_empty")] + pub extra: Map, +} + +impl Default for MaskEffect { + fn default() -> Self { + Self { + name: String::new(), + adjust: LocalAdjustments::default(), + group: String::new(), + favorite: false, + fields: Vec::new(), + with_amount: false, + brush: None, + extra: Map::new(), + } + } +} + +/// Field `k` of `a` (0 for an unknown name). +pub fn get(a: &LocalAdjustments, k: &str) -> f64 { + serde_json::to_value(a).ok().and_then(|v| v.get(k).and_then(Value::as_f64)).unwrap_or(0.0) +} + +/// `a` with field `k` set to `v` (unchanged for an unknown name). +pub fn set(a: &LocalAdjustments, k: &str, v: f64) -> LocalAdjustments { + let Ok(Value::Object(mut o)) = serde_json::to_value(a) else { return *a }; + if !o.contains_key(k) || !v.is_finite() { + return *a; + } + o.insert(k.into(), json!(v)); + serde_json::from_value(Value::Object(o)).unwrap_or(*a) +} + +/// The sliders of `a` away from 0 (the colour's hue only with its saturation). +pub fn changed_fields(a: &LocalAdjustments) -> Vec { + let mut out: Vec = FIELDS.iter().filter(|k| get(a, k).abs() > 1e-9 && **k != "color_hue").map(|k| k.to_string()).collect(); + if a.color_sat.abs() > 1e-9 { + out.push("color_hue".into()); + } + out +} + +impl MaskEffect { + /// The sliders this effect sets. + pub fn field_list(&self) -> Vec<&str> { + if self.fields.is_empty() { FIELDS.to_vec() } else { FIELDS.iter().copied().filter(|k| self.fields.iter().any(|f| f == k)).collect() } + } + /// `a` with this preset's sliders (and Amount, if it carries it). + pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments { + let mut out = if self.fields.is_empty() { + LocalAdjustments { amount: a.amount, ..self.adjust } + } else { + self.field_list().iter().fold(*a, |acc, k| set(&acc, k, get(&self.adjust, k))) + }; + if self.with_amount { + out.amount = self.adjust.amount; + } + out + } + /// Does `a` carry exactly this preset's sliders? A tint's hue doesn't count without its + /// saturation (it does nothing then). + pub fn matches(&self, a: &LocalAdjustments) -> bool { + let fields = self.field_list(); + let colorless = |x: &LocalAdjustments| x.color_sat.abs() < 1e-9; + let same = |k: &str| (get(&self.adjust, k) - get(a, k)).abs() < 1e-6; + fields.iter().all(|k| *k == "color_hue" && colorless(&self.adjust) && colorless(a) || same(k)) + && (!self.with_amount || (self.adjust.amount - a.amount).abs() < 1e-6) + } + /// Does the effect set nothing (all its sliders at 0, nothing kept from an import)? + pub fn is_empty(&self) -> bool { + self.extra.is_empty() && changed_fields(&self.adjust).iter().all(|k| !self.field_list().contains(&k.as_str())) + } + /// The same effect with its values in range and its field list normalised. + pub fn normalized(mut self) -> Self { + self.name = self.name.trim().to_string(); + self.group = self.group.trim().to_string(); + let amount = if self.with_amount { self.adjust.amount } else { 100.0 }; + self.adjust = super::masks::clamp_local(LocalAdjustments { amount, ..self.adjust }); + self.fields.retain(|f| FIELDS.contains(&f.as_str())); + if self.fields.iter().any(|f| f == "color_sat") && !self.fields.iter().any(|f| f == "color_hue") { + self.fields.push("color_hue".into()); + } + self.fields.sort_by_key(|f| FIELDS.iter().position(|k| k == f)); + self.fields.dedup(); + if self.fields.len() == FIELDS.len() { + self.fields.clear(); + } + self.brush = self.brush.map(BrushSettings::clamped); + self + } +} + +/// Compare names the way people number their presets: digit runs as numbers ("2 …" before +/// "10 …"), letters ignoring case. +pub fn natural_cmp(a: &str, b: &str) -> Ordering { + fn chunks(s: &str) -> Vec<(bool, String)> { + let mut out: Vec<(bool, String)> = Vec::new(); + for c in s.chars() { + let d = c.is_ascii_digit(); + match out.last_mut() { + Some((kind, text)) if *kind == d => text.push(c), + _ => out.push((d, c.to_string())), + } + } + out + } + let (x, y) = (chunks(a), chunks(b)); + for (p, q) in x.iter().zip(y.iter()) { + let o = match (p, q) { + ((true, m), (true, n)) => { + let (m, n) = (m.trim_start_matches('0'), n.trim_start_matches('0')); + m.len().cmp(&n.len()).then_with(|| m.cmp(n)) + } + ((_, m), (_, n)) => m.to_lowercase().cmp(&n.to_lowercase()), + }; + if o != Ordering::Equal { + return o; + } + } + x.len().cmp(&y.len()).then_with(|| a.cmp(b)) +} + +/// The Effect menu's order: groups in the order they first appear (ungrouped first), each with +/// its effects (indices into `effects`) sorted by name. +pub fn menu_order(effects: &[MaskEffect]) -> Vec<(String, Vec)> { + let mut groups: Vec<(String, Vec)> = Vec::new(); + if effects.iter().any(|e| e.group.is_empty()) { + groups.push((String::new(), Vec::new())); + } + for (i, e) in effects.iter().enumerate() { + match groups.iter_mut().find(|(g, _)| g.eq_ignore_ascii_case(&e.group)) { + Some((_, v)) => v.push(i), + None => groups.push((e.group.clone(), vec![i])), + } + } + for (_, v) in &mut groups { + v.sort_by(|a, b| match (effects.get(*a), effects.get(*b)) { + (Some(x), Some(y)) => natural_cmp(&x.name, &y.name), + _ => Ordering::Equal, + }); + } + groups +} + +/// Name of the effect the mask adjustments `a` match, if any (full presets before partial ones). +pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option { + let full = s.mask_effects.iter().filter(|e| e.fields.is_empty()).find(|e| e.matches(a)); + full.or_else(|| s.mask_effects.iter().find(|e| e.matches(a))).map(|e| e.name.clone()) +} + +fn position(s: &Session, name: &str) -> Option { + s.mask_effects.iter().position(|x| x.name.eq_ignore_ascii_case(name.trim())) +} + +/// Add or replace (same name, ignoring case) an effect; a replaced one keeps its star. +pub(crate) fn upsert(s: &mut Session, e: MaskEffect) { + match position(s, &e.name).and_then(|i| s.mask_effects.get_mut(i)) { + Some(x) => { + let favorite = x.favorite || e.favorite; + *x = MaskEffect { favorite, ..e }; + } + None => s.mask_effects.push(e), + } +} + +fn name_param(p: &Value, c: &str) -> Result { + name_of(p, "name", c) +} + +fn name_of(p: &Value, key: &str, c: &str) -> Result { + str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, format!("missing `{key}`"))) +} + +fn strings(p: &Value, key: &str) -> Option> { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) +} + +/// The names a command acts on: `names: [...]` or `name`. +fn names_param(p: &Value, c: &str) -> Result> { + match strings(p, "names").filter(|v| !v.is_empty()) { + Some(v) => Ok(v), + None => name_param(p, c).map(|n| vec![n]), + } +} + +fn effect_index(s: &Session, name: &str, c: &str) -> Result { + position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`"))) +} + +/// The selected mask's adjustments on the active photo. +fn selected_mask(s: &Session, p: &Value, c: &str) -> Result { + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mid = p + .get("id") + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .or(s.active_mask) + .ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; + let d = s.develop_of(id).unwrap_or_default(); + d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}"))) +} + +/// The kind of a mask's first component (`brush`, `linear`, `radial`, `subject`…): the tool the +/// effect is remembered for. +pub fn mask_tool(m: &Mask) -> Option { + m.components.first().and_then(|c| serde_json::to_value(&c.shape).ok()).and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string)) +} + +/// The adjustments a new mask made with `tool` starts with: the effect last applied to a mask of +/// that kind (Lightroom carries the chosen effect over to the next mask), else all at 0. +pub fn new_mask_adjust(s: &Session, tool: &str) -> LocalAdjustments { + s.mask_tool_effects + .get(tool) + .and_then(|name| position(s, name)) + .and_then(|i| s.mask_effects.get(i)) + .map(|e| super::masks::clamp_local(e.apply(&LocalAdjustments::default()))) + .unwrap_or_default() +} + +fn list(s: &Session) -> Value { + let current = s + .active() + .and_then(|id| s.develop_of(id)) + .and_then(|d| s.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust))) + .and_then(|a| matching_effect(s, &a)); + let groups: Vec = menu_order(&s.mask_effects) + .into_iter() + .map(|(g, ix)| json!({"name": g, "effects": ix.iter().filter_map(|i| s.mask_effects.get(*i)).map(|e| e.name.clone()).collect::>()})) + .collect(); + json!({"effects": s.mask_effects, "groups": groups, "current": current, "toolEffects": s.mask_tool_effects}) +} + +/// Masks on `d` named like one of `names` (ignoring case). +fn masks_named(d: &DevelopSettings, names: &[String]) -> Vec { + d.masks.iter().filter(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.name))).map(|m| m.id).collect() +} + +fn apply_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.applyEffect"; + let name = name_param(p, C)?; + let effect = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; + let d = s.develop_of(id).unwrap_or_default(); + // the masks: `ids`, `all`, else `id` or the selected one + let ids: Vec = if p.get("all").and_then(Value::as_bool).unwrap_or(false) { + d.masks.iter().map(|m| m.id).collect() + } else if let Some(a) = p.get("ids").and_then(Value::as_array) { + a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect() + } else { + vec![selected_mask(s, p, C)?.id] + }; + if ids.is_empty() { + return Err(bad(C, "no masks to apply the effect to")); + } + if let Some(missing) = ids.iter().find(|m| !d.masks.iter().any(|x| x.id == **m)) { + return Err(bad(C, format!("no mask {missing}"))); + } + let set_masks = |d: &DevelopSettings, ids: &[u32]| { + let mut d = d.clone(); + for m in d.masks.iter_mut().filter(|m| ids.contains(&m.id)) { + m.adjust = super::masks::clamp_local(effect.apply(&m.adjust)); + } + d + }; + let label = format!("Mask Effect: {}", effect.name); + // with `photos: "selected"`, the other selected photos' masks of the same names too + let others: Vec<(PhotoId, DevelopSettings)> = if str_param(p, "photos") == Some("selected") { + let names: Vec = d.masks.iter().filter(|m| ids.contains(&m.id)).map(|m| m.name.clone()).collect(); + s.selection + .ids + .iter() + .filter(|x| **x != id) + .filter_map(|x| s.develop_of(*x).map(|o| (*x, o))) + .filter_map(|(x, o)| { + let found = masks_named(&o, &names); + (!found.is_empty()).then(|| (x, set_masks(&o, &found))) + }) + .collect() + } else { + Vec::new() + }; + let mine = set_masks(&d, &ids); + let photos = 1 + others.len(); + if others.is_empty() { + s.set_develop(id, mine, &label)?; + } else { + // one undo step for every photo + let mut ops: Vec<_> = s.develop_op(id, mine, &label).into_iter().collect(); + ops.extend(others.into_iter().filter_map(|(x, o)| s.develop_op(x, o, &label))); + s.commit(&label, lightcraft_catalog::Op::Batch { ops })?; + } + // the next mask made with this tool starts with the effect + if p.get("remember").and_then(Value::as_bool).unwrap_or(true) { + for m in d.masks.iter().filter(|m| ids.contains(&m.id)) { + if let Some(tool) = mask_tool(m) { + s.mask_tool_effects.insert(tool, effect.name.clone()); + } + } + s.save_prefs()?; + } + Ok(json!({"name": effect.name, "masks": ids, "photos": photos, "brush": effect.brush})) +} + +/// The fields a save keeps: `fields: [..]` | `"changed"` | `"all"` (default). +fn fields_param(p: &Value, a: &LocalAdjustments, c: &str) -> Result> { + match p.get("fields") { + None | Some(Value::Null) => Ok(Vec::new()), + Some(Value::String(x)) if x == "all" => Ok(Vec::new()), + Some(Value::String(x)) if x == "changed" => { + let f = changed_fields(a); + if f.is_empty() { Err(bad(c, "the mask changes no slider; nothing to save")) } else { Ok(f) } + } + Some(Value::Array(v)) => { + let f: Vec = v.iter().filter_map(Value::as_str).filter(|k| FIELDS.contains(k)).map(str::to_string).collect(); + if f.is_empty() { Err(bad(c, format!("`fields` names no slider (one of {})", FIELDS.join(", ")))) } else { Ok(f) } + } + Some(_) => Err(bad(c, "`fields` is \"all\", \"changed\" or a list of slider names")), + } +} + +fn brush_param(p: &Value, c: &str) -> Result> { + match p.get("brush") { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(v) => serde_json::from_value::(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))), + } +} + +fn save_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.saveEffect"; + let name = name_param(p, C)?; + let adjust = selected_mask(s, p, C)?.adjust; + let group = str_param(p, "group").map(|g| g.trim().to_string()).unwrap_or_default(); + let e = MaskEffect { + name, + adjust, + group, + fields: fields_param(p, &adjust, C)?, + with_amount: p.get("amount").and_then(Value::as_bool).unwrap_or(false), + brush: brush_param(p, C)?, + ..Default::default() + }; + upsert(s, e.normalized()); + s.save_prefs()?; + Ok(list(s)) +} + +/// Overwrite an effect's sliders with the selected mask's (keeping which sliders and whether +/// Amount it carries, its group and star). +fn update_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.updateEffect"; + let name = name_param(p, C)?; + let i = effect_index(s, &name, C)?; + let adjust = selected_mask(s, p, C)?.adjust; + let brush = brush_param(p, C)?; + let e = s.mask_effects.get_mut(i).ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let mut next = MaskEffect { adjust, extra: Map::new(), ..e.clone() }; + if let Some(f) = p.get("fields").filter(|v| !v.is_null()) { + next.fields = fields_param(&json!({"fields": f}), &adjust, C)?; + } + if let Some(a) = p.get("amount").and_then(Value::as_bool) { + next.with_amount = a; + } + if brush.is_some() { + next.brush = brush; + } + *e = next.normalized(); + s.save_prefs()?; + Ok(list(s)) +} + +fn rename_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.renameEffect"; + let name = name_param(p, C)?; + let to = name_of(p, "to", C)?; + let i = effect_index(s, &name, C)?; + if position(s, &to).is_some_and(|j| j != i) { + return Err(bad(C, format!("an effect named `{to}` exists already"))); + } + if let Some(e) = s.mask_effects.get_mut(i) { + e.name = to.clone(); + } + for v in s.mask_tool_effects.values_mut() { + if v.eq_ignore_ascii_case(&name) { + *v = to.clone(); + } + } + s.save_prefs()?; + Ok(list(s)) +} + +/// A name like `base` that no effect has: `base`, `base 2`, `base 3`… +fn free_name(s: &Session, base: &str) -> String { + (1..10_000) + .map(|n| if n == 1 { base.to_string() } else { format!("{base} {n}") }) + .find(|n| position(s, n).is_none()) + .unwrap_or_else(|| base.into()) +} + +fn duplicate_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.duplicateEffect"; + let name = name_param(p, C)?; + let e = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let to = match str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()) { + Some(t) if position(s, t).is_some() => return Err(bad(C, format!("an effect named `{t}` exists already"))), + Some(t) => t.to_string(), + None => free_name(s, &format!("{} copy", e.name)), + }; + s.mask_effects.push(MaskEffect { name: to.clone(), favorite: false, ..e }); + s.save_prefs()?; + let mut out = list(s); + out["name"] = json!(to); + Ok(out) +} + +fn delete_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.deleteEffect"; + let names = names_param(p, C)?; + if let Some(missing) = names.iter().find(|n| position(s, n).is_none()) { + return Err(bad(C, format!("no effect `{missing}`"))); + } + s.mask_effects.retain(|x| !names.iter().any(|n| n.eq_ignore_ascii_case(&x.name))); + s.mask_tool_effects.retain(|_, v| !names.iter().any(|n| n.eq_ignore_ascii_case(v))); + s.save_prefs()?; + Ok(list(s)) +} + +fn favorite_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.favoriteEffect"; + let names = names_param(p, C)?; + let mut idx = Vec::new(); + for n in &names { + idx.push(effect_index(s, n, C)?); + } + let want = p.get("favorite").and_then(Value::as_bool); + for i in idx { + if let Some(e) = s.mask_effects.get_mut(i) { + e.favorite = want.unwrap_or(!e.favorite); + } + } + s.save_prefs()?; + Ok(list(s)) +} + +fn set_group(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.setEffectGroup"; + let names = names_param(p, C)?; + let group = str_param(p, "group").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `group` (\"\" = none)"))?; + let mut idx = Vec::new(); + for n in &names { + idx.push(effect_index(s, n, C)?); + } + // an existing group's spelling wins + let group = s.mask_effects.iter().map(|e| &e.group).find(|g| g.eq_ignore_ascii_case(&group)).cloned().unwrap_or(group); + for i in idx { + if let Some(e) = s.mask_effects.get_mut(i) { + e.group = group.clone(); + } + } + s.save_prefs()?; + Ok(list(s)) +} + +fn rename_group(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.renameEffectGroup"; + let group = name_of(p, "group", C)?; + let to = str_param(p, "to").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `to` (\"\" = ungroup)"))?; + let mut n = 0; + for e in s.mask_effects.iter_mut().filter(|e| e.group.eq_ignore_ascii_case(&group)) { + e.group = to.clone(); + n += 1; + } + if n == 0 { + return Err(bad(C, format!("no group `{group}`"))); + } + s.save_prefs()?; + Ok(list(s)) +} + +fn tool_effect(s: &mut Session, p: &Value) -> Result { + const C: &str = "mask.toolEffect"; + let tool = name_of(p, "tool", C)?; + match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => { + let i = effect_index(s, n, C)?; + let name = s.mask_effects.get(i).map(|e| e.name.clone()).unwrap_or_default(); + s.mask_tool_effects.insert(tool, name); + } + None => { + s.mask_tool_effects.remove(&tool); + } + } + s.save_prefs()?; + Ok(json!({"toolEffects": s.mask_tool_effects})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "mask.effects", + "Mask Effect Presets", + [], + None, + "{} → {effects: [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}], groups: [{name, effects: [name]}] (menu order), current: the effect the selected mask matches or null, toolEffects: {tool: effect a new mask of that kind starts with}}", + always, + |s, _| Ok(list(s)) + ), + cmd!( + "mask.applyEffect", + "Apply Mask Effect", + [], + None, + "{name, id? | ids?: [maskId] | all?: bool, photos?: \"selected\" (also the masks of the same names on the other selected photos, one undo step), remember?: bool (true: new masks of this kind start with it)} — sets the effect's sliders (all, or the ones it keeps; Amount only if it carries it) → {name, masks, photos, brush}", + has_active, + apply_effect + ), + cmd!( + "mask.saveEffect", + "Save Mask Effect", + [], + None, + "{name, id?, group?, fields?: \"all\" (default) | \"changed\" | [slider], amount?: bool (carry Amount), brush?: {size, feather, flow, density, auto_mask}} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library", + has_active, + save_effect + ), + cmd!( + "mask.updateEffect", + "Update Mask Effect", + [], + None, + "{name, id?, fields?, amount?, brush?} — overwrite the effect with the selected mask's sliders (keeps its group, star and which sliders it carries unless given)", + has_active, + update_effect + ), + cmd!("mask.renameEffect", "Rename Mask Effect", [], None, "{name, to}", always, rename_effect), + cmd!( + "mask.duplicateEffect", + "Duplicate Mask Effect", + [], + None, + "{name, to?} (default \" copy\") → list + {name}", + always, + duplicate_effect + ), + cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name | names: [name]} — removes effect presets", always, delete_effect), + cmd!("mask.favoriteEffect", "Favorite Mask Effect", [], None, "{name | names, favorite?: bool (default: toggle)}", always, favorite_effect), + cmd!("mask.setEffectGroup", "Move Mask Effect to Group", [], None, "{name | names, group: name (\"\" = none)}", always, set_group), + cmd!( + "mask.renameEffectGroup", + "Rename Mask Effect Group", + [], + None, + "{group, to} (\"\" ungroups; an existing name merges)", + always, + rename_group + ), + cmd!( + "mask.toolEffect", + "Set Effect for New Masks", + [], + None, + "{tool: brush|linear|radial|subject|…, name?} — the effect new masks of that kind start with (no name: none)", + always, + tool_effect + ), + cmd!( + "mask.importEffects", + "Import Mask Effects", + [], + None, + "{path | paths: [file or folder], group?, dryRun?} — Lightroom Classic local adjustment presets (.lrtemplate), Camera Raw local correction presets (.xmp), .lceffect files and .zip bundles of these; folders and Lightroom's divider presets (\"10 ----- DETAIL ----- 10\") become groups; same-named effects are replaced → {imported: [{name, group, file, unmapped}], dividers: [{name, group, file}], failed: [[file, error]]}", + always, + files::import_effects + ), + cmd!( + "mask.exportEffects", + "Export Mask Effects", + [], + None, + "{path, names?: [name], group?: name (default: all)} — writes an .lceffect JSON file (groups, stars and kept Lightroom settings included) → {path, count}", + always, + files::export_effects + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_keeps_amount_and_matches_ignore_it() { + let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..Default::default() }; + let current = LocalAdjustments { shadows: 40.0, amount: 60.0, ..Default::default() }; + let out = e.apply(¤t); + assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0)); + assert!(e.matches(&out)); + assert!(!e.matches(¤t)); + } + + #[test] + fn partial_effects_leave_other_sliders_and_can_carry_amount() { + let e = MaskEffect { + name: "Lift".into(), + adjust: LocalAdjustments { shadows: 30.0, clarity: 99.0, amount: 70.0, ..Default::default() }, + fields: vec!["shadows".into()], + with_amount: true, + ..Default::default() + }; + let current = LocalAdjustments { exposure: 0.5, clarity: 10.0, ..Default::default() }; + let out = e.apply(¤t); + assert_eq!((out.exposure, out.shadows, out.clarity, out.amount), (0.5, 30.0, 10.0, 70.0)); + assert!(e.matches(&out)); + assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried"); + } + + #[test] + fn hue_without_saturation_still_matches() { + let e = MaskEffect { + name: "E".into(), + adjust: LocalAdjustments { color_hue: 240.0, exposure: 0.25, ..Default::default() }, + ..Default::default() + }; + assert!(e.matches(&LocalAdjustments { exposure: 0.25, ..Default::default() })); + assert!(!e.matches(&LocalAdjustments { exposure: 0.25, color_sat: 10.0, ..Default::default() })); + } + + #[test] + fn natural_order_and_groups() { + let mut v = vec!["10 b", "2 a", "Burn", "01 x", "1 y"]; + v.sort_by(|a, b| natural_cmp(a, b)); + assert_eq!(v, vec!["01 x", "1 y", "2 a", "10 b", "Burn"]); + let e = |n: &str, g: &str| MaskEffect { name: n.into(), group: g.into(), ..Default::default() }; + let list = vec![e("12 b", "Detail"), e("3 a", ""), e("11 a", "detail"), e("20 r", "Retouch"), e("Burn", "")]; + let order = menu_order(&list); + let names: Vec<(String, Vec<&str>)> = order.iter().map(|(g, ix)| (g.clone(), ix.iter().map(|i| list[*i].name.as_str()).collect())).collect(); + assert_eq!( + names, + vec![("".to_string(), vec!["3 a", "Burn"]), ("Detail".to_string(), vec!["11 a", "12 b"]), ("Retouch".to_string(), vec!["20 r"]),] + ); + } + + #[test] + fn normalized_clamps_and_tidies_fields() { + let e = MaskEffect { + name: " X ".into(), + adjust: LocalAdjustments { exposure: 99.0, amount: 150.0, ..Default::default() }, + fields: vec!["color_sat".into(), "bogus".into(), "exposure".into()], + brush: Some(BrushSettings { size: f64::NAN, flow: 0.0, ..Default::default() }), + ..Default::default() + } + .normalized(); + assert_eq!(e.name, "X"); + assert_eq!(e.adjust.exposure, 4.0); + assert_eq!(e.adjust.amount, 100.0, "Amount isn't carried"); + assert_eq!(e.fields, vec!["exposure", "color_hue", "color_sat"]); + let b = e.brush.unwrap(); + assert_eq!((b.size, b.flow), (0.04, 1.0)); + } +} diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 25ff3f24da..0efb3b6584 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -150,6 +150,55 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec Vec { + let Some(&first) = to.points.first() else { return vec![to] }; + let same = |a: f64, b: f64| (a - b).abs() <= 1e-9; + // pen pressure along the line: the last dab's at its start, the click's at its end (none when neither has any) + let pressured = !last.pressure.is_empty() || !to.pressure.is_empty(); + let (p_from, p_to) = (last.pressure_at(0), to.pressure_at(0)); + if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) { + let mut points = Vec::with_capacity(to.points.len() + 1); + points.push(from); + points.extend(to.points.iter().copied()); + let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() }; + return vec![BrushStroke { points, pressure, ..to }]; + } + // about 5% of size, or 2 points of feather or flow, per piece + let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 }; + let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0] + .into_iter() + .filter(|v| v.is_finite()) + .fold(1.0f64, f64::max); + let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS); + let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t; + let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t)); + let mut out: Vec = (0..steps) + .map(|i| { + let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64); + let p = |t: f64| (f64::from(p_from) + (f64::from(p_to) - f64::from(p_from)) * t) as f32; + BrushStroke { + points: vec![at(t0), at(t1)], + size: lerp(last.size, to.size, t1), + feather: lerp(last.feather, to.feather, t1), + flow: lerp(last.flow, to.flow, t1), + pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() }, + ..to.clone() + } + }) + .collect(); + if to.points.len() > 1 { + out.push(to); + } + out +} + fn mask_id(p: &Value, active: Option, c: &str) -> Result { p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)")) } @@ -178,11 +227,14 @@ pub fn specs() -> Vec { return Ok(json!({"pending": true})); } let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); + // the effect last applied to a mask of this kind + let adjust = super::mask_effects::new_mask_adjust(s, &kind); let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| { masks.push(Mask { id: next, name: name.unwrap_or_else(|| format!("Mask {next}")), components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust, ..Default::default() }); *active = Some(next); @@ -230,7 +282,7 @@ pub fn specs() -> Vec { "Brush Stroke", [], None, - "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool}", + "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}", has_active, |s, p| { let pts: Vec = p @@ -240,6 +292,7 @@ pub fn specs() -> Vec { .iter() .filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))) .collect(); + let pts_len = pts.len(); let d = BrushStroke::default(); let stroke = BrushStroke { points: pts, @@ -249,14 +302,18 @@ pub fn specs() -> Vec { density: f64_or(p, "density", d.density), erase: bool_or(p, "erase", false), auto_mask: bool_or(p, "autoMask", false), + pressure: pressure_list(p.get("pressure"), pts_len)?, }; + let connect = bool_or(p, "connect", false); + let mut connected = false; let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask); - masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { + let adjust = super::mask_effects::new_mask_adjust(s, "brush"); + let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) { Some(i) => i, None => { - masks.push(Mask { id: next, name: format!("Mask {next}"), ..Default::default() }); + masks.push(Mask { id: next, name: format!("Mask {next}"), adjust, ..Default::default() }); *active = Some(next); masks.len() - 1 } @@ -265,7 +322,28 @@ pub fn specs() -> Vec { if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) = m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. })) { - strokes.push(stroke); + // the previous stroke's end and its brush values (not its points) + let anchor = strokes.last().filter(|_| connect).and_then(|l| { + Some(( + *l.points.last()?, + BrushStroke { + points: Vec::new(), + size: l.size, + feather: l.feather, + flow: l.flow, + // the pressure the last dab was painted with, when the stroke recorded any + pressure: if l.pressure.is_empty() { Vec::new() } else { vec![l.pressure_at(l.points.len().saturating_sub(1))] }, + ..BrushStroke::default() + }, + )) + }); + match anchor { + Some((from, last)) => { + strokes.extend(connected_strokes(from, &last, stroke)); + connected = true; + } + None => strokes.push(stroke), + } } else { m.components.push(MaskComponent { name: None, @@ -275,7 +353,11 @@ pub fn specs() -> Vec { }); } Ok(()) - }) + })?; + if let Some(o) = out.as_object_mut() { + o.insert("connected".into(), json!(connected)); + } + Ok(out) } ), cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| { @@ -921,7 +1003,7 @@ pub fn specs() -> Vec { has_active, |s, p| { let c = "redeye.catchlight"; - let i = super::f64_req(p, "index", c)? as usize; + let i = super::index_req(p, "index", c)?; let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?; @@ -936,7 +1018,7 @@ pub fn specs() -> Vec { ), cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| { let c = "redeye.delete"; - let i = super::f64_req(p, "index", c)? as usize; + let i = super::index_req(p, "index", c)?; let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); if i >= dd.red_eye.len() { @@ -979,7 +1061,7 @@ fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::Deve lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid) } -fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { +pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 }; c(&mut a.exposure, -4.0, 4.0); for v in [ @@ -1007,3 +1089,17 @@ fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { c(&mut a.amount, 0.0, 200.0); a } + +/// `mask.brushStroke`'s `pressure`: one number 0..1 per point (clamped), at most one per point; absent or `null` +/// is no pressure data. +fn pressure_list(v: Option<&Value>, points: usize) -> Result> { + let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))?; + if list.len() > points { + return Err(bad("mask.brushStroke", "pressure has more values than points")); + } + list.iter() + .map(|q| q.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0) as f32)) + .collect::>>() + .ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1")) +} diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index a8869153a7..c07065cde8 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -1,4 +1,4 @@ -//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`. +//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`, `merge.hdrBatch`. //! //! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with //! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames @@ -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); } @@ -40,6 +40,26 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result { Ok(info) } +/// `merge.hdrBatch`: an HDR merge of each bracket set among the photos ([`lightcraft_catalog::Catalog::bracket_sets`]), +/// one after the other. A set that fails is reported and the others still run. +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 sets = s.catalog.bracket_sets(&ids); + if sets.is_empty() { + return Err(bad(ID, crate::merge::NO_BRACKET_SETS)); + } + let (mut merged, mut failed) = (Vec::new(), Vec::new()); + for set in sets { + match s.merge_now(kind.clone(), finish.clone(), &set) { + Ok(v) => merged.push(v), + Err(e) => failed.push(json!({"photos": set.iter().map(|p| p.0).collect::>(), "error": e.to_string()})), + } + } + Ok(json!({"merged": merged, "failed": failed})) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -56,7 +76,7 @@ pub fn specs() -> Vec { "Panorama Merge", [], None, - "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}", + "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=full size (≤ 512, held to memory), preview=false, previewPath?}", can_merge, |s, p| run(s, "merge.panorama", p) ), @@ -69,5 +89,14 @@ pub fn specs() -> Vec { can_merge, |s, p| run(s, "merge.hdrPanorama", p) ), + cmd!( + "merge.hdrBatch", + "HDR for Each Bracket Set", + ["Photo", "Photo Merge"], + None, + "{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false}: an HDR merge of each bracket set among the photos (each stack, then photos taken less than a second apart at exposures ≥ ½ EV apart) → {merged: [{id, path, …}], failed: [{photos, error}]}", + can_merge, + run_batch + ), ] } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761f..4b86d40127 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,11 +19,14 @@ pub(crate) mod face_detect; mod face_models; mod face_recognize; pub mod filters; +mod focus; pub mod geocode; pub mod keywords; pub mod library; pub mod lut_profiles; pub mod manage; +pub mod mask_effect_files; +pub mod mask_effects; mod masks; mod merge; pub mod metadata; @@ -32,7 +35,10 @@ mod organize; mod prefs; mod preset_files; pub mod previews; +mod print; mod query; +pub mod recheck; +mod slideshow; mod sync; mod xmp; @@ -105,7 +111,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)"; @@ -145,6 +151,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(manage::specs()); v.extend(previews::specs()); v.extend(lut_profiles::specs()); + v.extend(mask_effects::specs()); v.extend(cull::specs()); v.extend(convert::specs()); v.extend(convert::edit_specs()); @@ -153,10 +160,13 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(xmp::specs()); v.extend(preset_files::specs()); 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()); + v.extend(recheck::specs()); v.extend(sync::specs()); v.extend(metadata::specs()); v.extend(geocode::specs()); @@ -165,6 +175,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(face_models::specs()); v.extend(face_detect::specs()); v.extend(face_recognize::specs()); + v.extend(focus::specs()); v.extend(activity::specs()); v }) @@ -187,6 +198,15 @@ pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) } +/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize` +/// saturates to 0), just as `history.restore` already refuses a negative step index. +pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result { + let i = f64_req(p, key, cmd)?; + if !(i.is_finite() && i >= 0.0) { + return Err(bad(cmd, format!("`{key}` must be a non-negative integer"))); + } + Ok(i as usize) +} pub(crate) fn f64_or(p: &Value, key: &str, d: f64) -> f64 { p.get(key).and_then(Value::as_f64).unwrap_or(d) } diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 4947856281..24cc2061c4 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/prefs.rs b/crates/engine/src/cmd/prefs.rs index 78de9d8500..1f1301a511 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -43,6 +43,9 @@ fn prefs(s: &mut Session, p: &Value) -> Result { if let Some(v) = i.get("perCamera").and_then(Value::as_bool) { d.per_camera = v; } + if let Some(v) = i.get("showOnCard").and_then(Value::as_bool) { + d.show_on_card = v; + } if let Some(v) = i.get("copyright").and_then(Value::as_str) { d.copyright = v.trim().to_string(); } @@ -107,7 +110,7 @@ pub fn specs() -> Vec { "Library Preferences", [], None, - "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}", + "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one), showOnCard?: bool (open the import review when a camera or card is connected)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}", always, prefs )] diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index fc385f34c5..b64a69fc76 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -2,6 +2,7 @@ use serde_json::{Value, json}; +use super::mask_effect_files as mef; use super::{CommandSpec, always, bad, cmd, str_param}; use crate::preset_import::{group_from_dir, read_presets}; use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset}; @@ -18,6 +19,10 @@ fn import(s: &mut Session, p: &Value) -> Result { let mut imported = Vec::new(); let mut failed = Vec::new(); let mut skipped = 0usize; + // local adjustment (brush) presets — Lightroom's .lrtemplate, Camera Raw's local .xmp, our + // .lceffect, also inside zips — become mask effects + let mut effects = Vec::new(); + let mut effect_failed = Vec::new(); for top in &paths { let from_dir = std::path::Path::new(top).is_dir(); for f in expand_preset_paths(std::slice::from_ref(top)) { @@ -25,18 +30,47 @@ fn import(s: &mut Session, p: &Value) -> Result { // the path from the imported folder (inclusive) to the file's folder let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new("")); let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string()); - let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d)); - let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group)); + let rel = rel.filter(|_| from_dir); + let bytes = match mef::read_capped(&f) { + Ok(b) => b, + Err(e) => { + failed.push(json!([f, e])); + continue; + } + }; + let lower = f.to_ascii_lowercase(); + let maybe_local = [".lrtemplate", ".xmp", ".zip", ".lceffect"].iter().any(|x| lower.ends_with(x)); + let before = effects.len() + effect_failed.len(); + let local = maybe_local && mef::found_in(&f, &bytes, rel.as_deref().and_then(mef::group_for_dir), &mut effects, &mut effect_failed); + // a zip may hold develop presets as well + if local && !lower.ends_with(".zip") { + continue; + } + let dir_group = rel.and_then(|d| group_from_dir(&d)); + let parsed = read_presets(&f, &bytes, dir_group); + if local && parsed.is_err() && effects.len() + effect_failed.len() > before { + continue; + } match parsed { Ok(list) => { for mut it in list { 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, } } @@ -45,7 +79,9 @@ fn import(s: &mut Session, p: &Value) -> Result { } } } - Ok(json!({"imported": imported, "skipped": skipped, "failed": failed})) + failed.extend(effect_failed.into_iter().map(|(f, e)| json!([f, e]))); + let (effects, dividers) = mef::add_found(s, effects, group.as_deref(), dry)?; + Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects, "effectDividers": dividers})) } fn export(s: &mut Session, p: &Value) -> Result { @@ -69,10 +105,9 @@ fn export(s: &mut Session, p: &Value) -> Result { if chosen.is_empty() { return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)")); } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad("preset.export", e))?; + crate::export::write_file_durable(&target, to_lcpreset(&chosen).as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } @@ -83,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 → {imported: [{id,name,group,file,unmapped}], skipped, failed}", + "{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 4fc7ababa6..b63ccc2b45 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 0000000000..05a8518933 --- /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/recheck.rs b/crates/engine/src/cmd/recheck.rs new file mode 100644 index 0000000000..494426b90c --- /dev/null +++ b/crates/engine/src/cmd/recheck.rs @@ -0,0 +1,129 @@ +//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or +//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file. +//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a +//! library opens in another version, its stale preview-only photos are probed again and get +//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that +//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again. +//! +//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no +//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the +//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use +//! `library.recheckPreviewOnly`, which does all three at once. + +use lightcraft_catalog::{Catalog, Op, Photo, PhotoId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; +use crate::media::{FileProbe, ProbeInfo}; +use crate::{Result, Session}; + +/// A preview-only photo to probe, and what the library knew about it when it was listed (so a +/// result for a photo that changed meanwhile is dropped). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stale { + pub id: PhotoId, + pub path: String, + preview_only: Option, + preview_checked: String, +} + +impl Stale { + fn of(p: &Photo, path: &str) -> Stale { + Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() } + } + + /// The photo is still as it was listed: same file, flag and stamp. + fn current(&self, p: &Photo) -> bool { + crate::cmd::missing::checked_path(p) == Some(self.path.as_str()) + && p.preview_only == self.preview_only + && p.preview_checked == self.preview_checked + } +} + +/// The library's preview-only photos whose flag another app version set (or every preview-only +/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they +/// were copied from). No file-system calls. +pub fn stale(cat: &Catalog, all: bool) -> Vec { + cat.photos() + .filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale())) + .filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path))) + .collect() +} + +/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no +/// file access (a demo or browser session), where nothing can be rechecked. +pub fn file_probe(s: &Session) -> Option { + s.media.file_probe.clone() +} + +/// Probe each listed photo's file as Reload would. Doesn't touch the session. +pub fn probe(probe: &FileProbe, stale: Vec) -> Vec<(Stale, std::result::Result)> { + let paths: Vec = stale.iter().map(|x| x.path.clone()).collect(); + stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect() +} + +/// What a recheck changed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rechecked { + /// Photos brought up to date (virtual copies included). + pub updated: Vec, + /// Of those, the ones that develop from their raw data now. + pub cleared: Vec, +} + +impl Rechecked { + pub fn to_json(&self) -> Value { + json!({"updated": self.updated.iter().map(|i| i.0).collect::>(), "cleared": self.cleared.iter().map(|i| i.0).collect::>()}) + } +} + +/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is +/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing, +/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded, +/// removed) is skipped. +pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result)>) -> Result { + let mut ops = Vec::new(); + let mut out = Rechecked::default(); + for (item, info) in results { + let Ok(info) = info else { continue }; + if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) { + continue; + } + let first = out.updated.len(); + crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated); + if info.preview_only.is_none() { + out.cleared.extend_from_slice(&out.updated[first..]); + } + } + if !ops.is_empty() { + s.apply_system(Op::Batch { ops })?; + } + for id in &out.updated { + s.media.forget(*id); + } + Ok(out) +} + +fn recheck(s: &mut Session, p: &Value) -> Result { + const C: &str = "library.recheckPreviewOnly"; + let all = p.get("all").and_then(Value::as_bool).unwrap_or(false); + // without file access every probe "succeeds" with nothing known, which would clear every flag + let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?; + let listed = stale(&s.catalog, all); + let n = listed.len(); + let mut r = apply(s, probe(&fp, listed))?.to_json(); + r["checked"] = json!(n); + Ok(r) +} + +pub fn specs() -> Vec { + vec![cmd!( + "library.recheckPreviewOnly", + "Recheck Preview-Only Raws", + [], + None, + "{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}", + always, + recheck + )] +} diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs new file mode 100644 index 0000000000..33ff76ec0f --- /dev/null +++ b/crates/engine/src/cmd/slideshow.rs @@ -0,0 +1,513 @@ +//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour, +//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks +//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop +//! colour, colour wash, background image). Both are library preferences, saved in the library's +//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`: +//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every +//! slide (also a library preference). `slideshow.titles`: the intro and ending screens (colour, +//! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes +//! one slide, or a title screen, as a PNG (with the library's settings, or a template's). +//! `slideshow.templates.*`: the Template Browser (built-in and user templates; see +//! [`crate::slide_template`]). `slideshow.export`: the slideshow as a PDF, a JPEG sequence or an +//! AV1 video, in the background ([`crate::slideshow_export`]); `slideshow.exportStatus` reads how +//! it is going. + +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::slide_template::{self as tpl, SlideLook}; +use crate::{Result, Session}; + +/// Largest side `slideshow.preview` renders. +pub const PREVIEW_MAX: usize = 4096; + +fn playback(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slideshow.merged(p).map_err(|e| bad("slideshow.playback", e))?; + if next != s.slideshow { + s.slideshow = next; + s.save_prefs()?; + } + } + Ok(s.slideshow.to_json()) +} + +fn layout(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?; + if next != s.slide_layout { + s.slide_layout = next; + s.save_prefs()?; + } + } + Ok(s.slide_layout.to_json()) +} + +fn overlays(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_overlays.merged(p).map_err(|e| bad("slideshow.overlays", e))?; + if next != s.slide_overlays { + s.slide_overlays = next; + s.save_prefs()?; + } + } + let mut out = s.slide_overlays.to_json(); + out["warnings"] = json!(s.slide_overlays.warnings()); + Ok(out) +} + +fn titles(s: &mut Session, p: &Value) -> Result { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_titles.merged(p).map_err(|e| bad("slideshow.titles", e))?; + if next != s.slide_titles { + s.slide_titles = next; + s.save_prefs()?; + } + } + let mut out = s.slide_titles.to_json(); + out["warnings"] = json!(s.slide_titles.warnings(&s.slide_overlays)); + Ok(out) +} + +/// A slide size from the request: both sides given, one (the other from the aspect preview), or +/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`]. +fn preview_size(layout: &lightcraft_slideshow::SlideSettings, p: &Value) -> Result<(usize, usize)> { + const C: &str = "slideshow.preview"; + let side = |k: &str| -> Result> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)), + _ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))), + }, + } + }; + let ratio = layout.aspect.ratio(f64::NAN); + let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX); + Ok(match (side("width")?, side("height")?) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, clamp(w as f64 / ratio)), + (None, Some(h)) => (clamp(h as f64 * ratio), h), + (None, None) => (1280, clamp(1280.0 / ratio)), + }) +} + +/// Write `png` to `path`, or put it in the reply as base64. +fn deliver(png: &[u8], path: Option<&str>, out: &mut Value) -> Result<()> { + match path { + Some(path) => { + crate::export::write_file(path, png).map_err(crate::EngineError::Other)?; + out["path"] = json!(path); + } + None => out["png"] = json!(base64(png)), + } + Ok(()) +} + +fn encode_png(img: &lightcraft_raster::Rgba8) -> Result> { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()) + .map_err(|e| crate::EngineError::Other(format!("PNG: {e}"))) +} + +/// `slideshow.preview {title}`: a title screen (whether or not it is switched on). +fn preview_title(s: &mut Session, p: &Value, title: &Value, look: &SlideLook) -> Result { + const C: &str = "slideshow.preview"; + let t = title + .as_str() + .and_then(crate::slideshow::Title::parse) + .ok_or_else(|| bad(C, format!("`title` must be \"intro\" or \"ending\", not {title}")))?; + if p.get("id").is_some_and(|v| !v.is_null()) { + return Err(bad(C, "pass `id` or `title`, not both")); + } + let (w, h) = preview_size(&look.layout, p)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); + if let Some(path) = path { + s.check_write_target(path).map_err(|e| bad(C, e))?; + } + let c = crate::slide_title::TitleCompose::new(&look.titles, &look.overlays, t, w, h); + let img = c.compose(); + let png = encode_png(&img)?; + let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + let mut out = json!({ + "title": t.id(), + "enabled": look.titles.screen(t).enabled, + "width": img.width, + "height": img.height, + "plate": c.plate_box().map(rect), + }); + deliver(&png, path, &mut out)?; + Ok(out) +} + +fn preview(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.preview"; + // a template's look (the Template Browser's hover preview), else the library's settings + let template = match p.get("template") { + None | Some(Value::Null) => None, + Some(Value::String(id)) => Some(s.slide_template(id.trim()).map_err(|e| bad(C, e))?), + Some(v) => return Err(bad(C, format!("`template` must be a template id, not {v}"))), + }; + let look = template.as_ref().map_or_else(|| SlideLook::of(s), |t| t.look.clone()); + if let Some(t) = p.get("title").filter(|v| !v.is_null()) { + let mut out = preview_title(s, p, t, &look)?; + if let Some(t) = &template { + out["template"] = json!(t.id); + } + return Ok(out); + } + let id = match p.get("id") { + None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?, + Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?), + }; + if s.catalog.photo(id).is_none() { + return Err(bad(C, format!("no photo {}", id.0))); + } + let (w, h) = preview_size(&look.layout, p)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); + if let Some(path) = path { + s.check_write_target(path).map_err(|e| bad(C, e))?; + } + let settings = look.layout.clone(); + let img = s.slide_now_look(&settings, &look.overlays, id, w, h).map_err(crate::EngineError::Other)?; + let png = encode_png(&img)?; + let aspect = s.slide_photo_aspect(id).unwrap_or(1.5); + let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height); + let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + let mut out = json!({ + "id": id.0, + "width": img.width, + "height": img.height, + "cell": rect(l.cell), + "photo": rect(l.visible), + }); + let photo = s.catalog.photo(id).cloned(); + let o = crate::slide_overlay::SlideOverlays::resolve(&look.overlays, photo.as_deref()); + out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect(); + if let Some(t) = &template { + out["template"] = json!(t.id); + } + deliver(&png, path, &mut out)?; + Ok(out) +} + +/// Standard padded base64 (for an image returned in the reply). +fn base64(bytes: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char; + out.push(at(18)); + out.push(at(12)); + out.push(if chunk.len() > 1 { at(6) } else { '=' }); + out.push(if chunk.len() > 2 { at(0) } else { '=' }); + } + out +} + +// ------------------------------------------------------------------------------- templates + +/// The settings as the `slideshow.*` commands return them (for template replies). +fn settings_json(s: &Session) -> Value { + json!({ + "playback": s.slideshow.to_json(), + "layout": s.slide_layout.to_json(), + "overlays": s.slide_overlays.to_json(), + "titles": s.slide_titles.to_json(), + }) +} + +fn tpl_list(s: &mut Session, _p: &Value) -> Result { + let l = s.slide_templates(); + Ok(json!({ + "templates": l.templates.iter().map(|t| t.info()).collect::>(), + "previous": s.slide_templates_previous.is_some(), + "folder": s.slide_templates_dir.as_ref().map(|d| d.to_string_lossy().to_string()), + "problems": l.problems, + })) +} + +/// The template `id` or `name` names. +fn named(s: &mut Session, p: &Value, c: &str) -> Result> { + s.slide_template_named(str_param(p, "id"), str_param(p, "name")).map_err(|e| bad(c, e)) +} + +fn tpl_apply(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.templates.apply"; + let t = named(s, p, C)?; + let warnings = s.apply_slide_look(&t.look)?; + let mut out = json!({"applied": t.id, "name": t.name, "warnings": warnings, "previous": s.slide_templates_previous.is_some()}); + out["settings"] = settings_json(s); + Ok(out) +} + +fn tpl_reply(t: &tpl::Template) -> Value { + t.info() +} + +fn tpl_save(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.templates.save"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let replace = match p.get("replace") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad(C, format!("`replace` must be true or false, not {v}")))?, + }; + let t = s.save_slide_template(name, replace).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn id_param<'a>(p: &'a Value, c: &str) -> Result<&'a str> { + str_param(p, "id").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(c, "missing `id` (a template id from slideshow.templates.list)")) +} + +fn tpl_update(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.templates.update"; + let id = id_param(p, C)?; + let t = s.update_slide_template(id).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn tpl_rename(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.templates.rename"; + let id = id_param(p, C)?; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let t = s.rename_slide_template(id, name).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn tpl_delete(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.templates.delete"; + let id = id_param(p, C)?; + let t = s.delete_slide_template(id).map_err(|e| bad(C, e))?; + Ok(json!({"deleted": t.id, "name": t.name})) +} + +fn tpl_import(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.templates.import"; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let (t, renamed) = s.import_slide_template(std::path::Path::new(path)).map_err(|e| bad(C, e))?; + let mut out = tpl_reply(&t); + out["renamed"] = json!(renamed); + out["warnings"] = json!(t.look.warnings(s)); + Ok(out) +} + +fn tpl_export(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.templates.export"; + let id = id_param(p, C)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let t = s.slide_template(id).map_err(|e| bad(C, e))?; + s.check_write_target(path).map_err(|e| bad(C, e))?; + let bytes = serde_json::to_vec_pretty(&t.to_doc()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + crate::export::write_file(path, &bytes).map_err(crate::EngineError::Other)?; + Ok(json!({"id": t.id, "name": t.name, "path": path})) +} + +/// `slideshow.export`: prepare on this thread, run on a worker (or here with `wait`). +fn export(s: &mut Session, p: &Value) -> Result { + const C: &str = "slideshow.export"; + if cfg!(target_arch = "wasm32") { + return Err(bad(C, "slideshow export needs the desktop app or lightcraft-cli")); + } + if s.slideshow_export.as_ref().is_some_and(|r| r.running()) { + return Err(bad(C, "a slideshow export is already running (activity.cancel stops it)")); + } + let prepared = crate::slideshow_export::prepare(s, p).map_err(|e| bad(C, e))?; + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let task = s.activity.start("export", "Exporting slideshow", crate::activity::Cancel::Flag(cancel.clone())); + let total = prepared.total(); + task.progress(0, total); + task.detail(&prepared.request.path); + let settings = &prepared.request.settings; + let run = std::sync::Arc::new(crate::slideshow_export::ExportRun { + task_id: task.id(), + format: settings.format.id().into(), + path: prepared.request.path.clone(), + total: total.into(), + cancel, + ..Default::default() + }); + s.slideshow_export = Some(run.clone()); + let started = json!({ + "taskId": task.id(), + "background": !wait, + "format": settings.format.id(), + "path": prepared.request.path, + "slides": prepared.len(), + "seconds": prepared.seconds(), + "total": total, + "width": prepared.width, + "height": prepared.height, + "warnings": prepared.warnings, + }); + if wait { + crate::slideshow_export::run_to_end(prepared, &run, &task); + drop(task); + return match run.result() { + Some(Ok(v)) => Ok(v), + Some(Err(e)) => Err(bad(C, e)), + None => Err(bad(C, "the export did not finish")), + }; + } + #[cfg(not(target_arch = "wasm32"))] + { + // the row goes when the worker ends, however it ends + let r = run.clone(); + std::thread::Builder::new() + .name("lc-slideshow-export".into()) + .spawn(move || crate::slideshow_export::run_to_end(prepared, &r, &task)) + .map_err(|e| bad(C, format!("could not start: {e}")))?; + } + Ok(started) +} + +fn export_status(s: &mut Session, _: &Value) -> Result { + Ok(s.slideshow_export.as_ref().map_or_else(|| json!({"running": false}), |r| r.json())) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "slideshow.playback", + "Slideshow Playback Settings", + [], + None, + "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", + always, + playback + ), + cmd!( + "slideshow.layout", + "Slideshow Layout Settings", + [], + None, + "{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings", + always, + layout + ), + cmd!( + "slideshow.overlays", + "Slideshow Overlay Settings", + [], + None, + "{identityPlate?: {enabled?, text?, image?: PNG/JPEG file (drawn instead of the text; \"\" = the text), size?: height as a fraction of the slide's shorter side (0.005–0.5), color?: \"#rrggbb\"|[r,g,b], opacity?: 0–1, …placement, behind?: bool (render behind the photo)}, ratingStars?: {enabled?, color?, size?: a star's height (0.005–0.15), …placement}, texts?: [text overlay] (replaces the list, at most 16; each entry merged onto the one at its index), addText?: text overlay ({} = the default \"{file}\"), removeText?: index, showWatermark?: bool, watermark?: text | the export watermark object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth} (merged; sizes relative to the photo)} where a text overlay is {text?: template with tokens ({file}, {name}, {title}, {caption}, {date} (YYYY-MM-DD) / {date:%d.%m.%Y}, {exposure}, {shutter}, {aperture}, {focal}, {iso}, {camera}, {lens}, {rating}, {creator}, {copyright}, {folder}, {ext}; unknown tags show as typed), size?: line height (0.005–0.3), color?, opacity?, …placement, shadow?: bool, shadowOpacity?: 0–1, shadowOffset?, shadowRadius?: 0–0.05 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right} and the placement keys are anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, offsetX?, offsetY?: fractions of the slide's shorter side (x right, y down; −1–1), relativeTo?: \"slide\"|\"photo\" (the overlay is kept on the slide) — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library; slideshow playback and slideshow.preview draw them → the settings and warnings (an unreadable identity plate image, unknown tokens)", + always, + overlays + ), + cmd!( + "slideshow.titles", + "Slideshow Title Settings", + [], + None, + "{intro?: screen, ending?: screen} where a screen is {enabled?: bool, color?: background \"#rrggbb\"|[r,g,b], content?: \"none\"|\"identityPlate\" (the Overlays identity plate, its text or image)|\"text\", text?: the text shown with content \"text\", overrideColor?: bool (draw the plate or text in textColor instead of the identity plate's colour; an image plate keeps its colours), textColor?: colour, scale?: height of the plate or text line as a fraction of the slide's shorter side (0.005–0.5; text wider than the slide shrinks to fit), duration?: seconds (0.5–120) | null (the slide duration)} — the intro plays once before the first slide, the ending after the last one when repeat is off; both fade with the playback fade; random order never moves them. Omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings and warnings (an unreadable identity plate image, a screen with nothing to show)", + always, + titles + ), + cmd!( + query "slideshow.templates.list", + "Slideshow Templates", + [], + None, + "{} → {templates: [{id: \"builtin:…\"|\"user:…\", name, builtin, group: \"builtin\"|\"user\", description?, path? (a user template's file)}], previous: bool (the settings before the last apply can be restored with apply {id: \"previous\"}), folder: the user templates folder | null, problems: [\"file: why it was skipped\"]} — built-ins first, then user templates by name (files in /slideshow-templates, at most 1000)", + always, + tpl_list + ), + cmd!( + "slideshow.templates.apply", + "Apply Slideshow Template", + [], + None, + "{id: template id | \"previous\" (the settings before the last apply) | name: template name (case-insensitive)} — replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's and saves them with the library; there is no undo: the replaced settings are kept as \"previous\" for this session → {applied, name, warnings: [a background photo not in this library, an unreadable identity plate / watermark image…], previous, settings: {playback, layout, overlays, titles}}", + always, + tpl_apply + ), + cmd!( + "slideshow.templates.save", + "Save Slideshow Template", + [], + None, + "{name: 1–80 characters, replace?: bool (over a user template of that name)} — the current playback, layout, overlays and titles settings as a user template, /slideshow-templates/.json (path separators and other reserved characters become _); a name taken (case-insensitive) or a built-in's is refused unless replace → {id, name, builtin, group, path}", + always, + tpl_save + ), + cmd!( + "slideshow.templates.update", + "Update Slideshow Template", + [], + None, + "{id: user template} — replace its settings with the current ones (name kept); built-ins can't change → {id, name, builtin, group, path}", + always, + tpl_update + ), + cmd!( + "slideshow.templates.rename", + "Rename Slideshow Template", + [], + None, + "{id: user template, name} — its file follows the name; a name taken is refused → {id (the new one), name, builtin, group, path}", + always, + tpl_rename + ), + cmd!( + "slideshow.templates.delete", + "Delete Slideshow Template", + [], + None, + "{id: user template} — deletes its file → {deleted, name}", + always, + tpl_delete + ), + cmd!( + "slideshow.templates.import", + "Import Slideshow Template", + [], + None, + "{path: a template .json file (at most 256 KB)} — checked like a saved one (format, version, every setting) and saved as a user template under its name (else the file's); a name taken gets \" (2)\"… → {id, name, builtin, group, path, renamed, warnings}", + always, + tpl_import + ), + cmd!( + "slideshow.templates.export", + "Export Slideshow Template", + [], + None, + "{id: template (built-in or user), path: .json file to write} → {id, name, path}", + always, + tpl_export + ), + cmd!( + query "slideshow.preview", + "Slideshow Slide Preview", + [], + None, + "{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", template?: a template id (slideshow.templates.list): compose with its settings instead of the library's, which don't change (the Template Browser's hover preview), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}", + always, + preview + ), + cmd!( + query "slideshow.export", + "Export Slideshow", + [], + None, + "{format: \"pdf\"|\"jpeg\"|\"video\", path: the .pdf / .mp4 / .webm file, or for jpeg the folder (created if missing), size?: pdf/jpeg the long side in pixels (64–8192, default 1920), video the frame height (64–2160 or \"720p\"/\"1080p\", default 720), quality?: 1–100 (JPEG quality, default 90; video default 75), fps?: 24|25|30|60 (video, default 30), container?: \"mp4\"|\"webm\" (video, default mp4), fullScreen?: bool (pdf: open in the viewer's full screen mode, default true), speed?: 1–10 (video encoder speed, default 10 = fastest), name?: jpeg file name prefix (default the folder's name: -001.jpg…), ids?: photo ids (default the photos in view, in view order), wait?: bool (run here and reply with the result)} — the photos' slides with the slideshow layout, overlays and the intro/ending screens that are on; video is AV1 (no audio), each slide held for its duration and fades rendered as frames, at most 2 hours. Runs in the background with a row in the activity stack (activity.cancel {id: taskId} stops it; nothing is left behind: files are written as .part and renamed when complete); a photo that can't be rendered is skipped with a warning → {taskId, background, format, path, slides, seconds, total, width, height, warnings}; with wait: the result (slideshow.exportStatus)", + always, + export + ), + cmd!( + query "slideshow.exportStatus", + "Slideshow Export Status", + [], + None, + "{} → the last slideshow export: {taskId, format, path, running, done, total (slides, or frames for video), cancelled, result?: {format, path, width, height, slides, skipped, warnings, pages? | files? | frames, samples, keyFrames, seconds, fps, container, codec: \"av1\", audio: false; bytes}, error?} | {running: false} when there was none", + always, + export_status + ), + ] +} diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index cd89aa8969..fb0ec9883e 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); @@ -108,6 +108,18 @@ pub fn specs() -> Vec { always, prefs ), + cmd!( + "library.retrySidecars", + "Write Waiting XMP Sidecars", + [], + None, + "{} — writes again the automatic XMP sidecars that failed (folder offline, read-only or full) whose originals are reachable now → {written, waiting}", + always, + |s, _| { + let written = s.retry_unsaved_sidecars(); + Ok(json!({"written": written, "waiting": s.unsaved_sidecars()})) + } + ), cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| { let on = !s.xmp.auto_write; prefs(s, &json!({"autoWrite": on})) diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index e83ad8c67a..381e842185 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -1,4 +1,5 @@ -//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF. +//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF +//! ([`crate::pdf`]). use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image}; use crate::{Session, media::RenderJob}; use lightcraft_pipeline::{OutputDepth, OutputSpace}; @@ -82,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()); } @@ -114,7 +115,7 @@ impl Prepared { let capacity = self.options.columns * self.options.rows; let photos = self.items.len(); let pages = photos.div_ceil(capacity); - let mut pdf = Pdf::new(pages, l.points); + let mut pdf = crate::pdf::Pdf::new(Vec::new())?; let mut page = Rgba8::filled(l.width, l.height, [255; 4]); for (i, (name, job)) in self.items.into_iter().enumerate() { if !progress(i, &name) { @@ -149,17 +150,17 @@ impl Prepared { } if slot + 1 == capacity || i + 1 == photos { let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?; - if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 { + if pdf.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 { return Err("contact sheet exceeds 256 MiB; select fewer photos".into()); } - pdf.page(i / capacity, &jpg, l.width, l.height); + pdf.image_page(&jpg, l.width, l.height, l.points, "")?; page.data.fill([255; 4]); } } if !progress(photos, "PDF") { return Err("Contact sheet cancelled".into()); } - Ok(Document { bytes: pdf.finish(), pages, photos }) + Ok(Document { bytes: pdf.finish("")?, pages, photos }) } } @@ -196,67 +197,6 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> Ok(()) } -// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets. -struct Pdf { - bytes: Vec, - offsets: Vec, - points: (f64, f64), -} - -impl Pdf { - fn new(pages: usize, points: (f64, f64)) -> Self { - let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points }; - p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>"); - let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::>().join(" "); - p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes()); - p - } - - fn object(&mut self, id: usize, body: &[u8]) { - self.offsets.push(self.bytes.len()); - self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes()); - self.bytes.extend_from_slice(body); - self.bytes.extend_from_slice(b"\nendobj\n"); - } - - fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) { - let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes(); - body.extend_from_slice(bytes); - body.extend_from_slice(b"\nendstream"); - self.object(id, &body); - } - - fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) { - let id = 3 + index * 3; - let (w, h) = self.points; - self.object( - id, - format!( - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>", - id + 2, - id + 1 - ) - .as_bytes(), - ); - self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes()); - self.stream( - id + 2, - &format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), - jpg, - ); - } - - fn finish(mut self) -> Vec { - let start = self.bytes.len(); - self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes()); - for offset in self.offsets.iter().skip(1) { - self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); - } - self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes()); - self.bytes - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 483d1778b3..f5384e23dc 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -64,13 +64,20 @@ const LOCAL: &[(&str, &str, f64)] = &[ ("LocalClarity", "clarity", 100.0), ]; -fn adjust(c: &XmpValue) -> Value { +/// Is `crs` (without the `crs:` prefix) a local slider field we map? +pub(crate) fn is_local_field(crs: &str) -> bool { + LOCAL.iter().any(|(k, _, _)| *k == crs) +} + +/// Our local adjustment values from the `Local*` slider fields that `get` finds (the name without +/// the `crs:` prefix → the stored fraction); process-version 2012 fields win over the older ones. +pub(crate) fn local_values(get: impl Fn(&str) -> Option) -> serde_json::Map { let mut a = serde_json::Map::new(); for (crs, ours, k) in LOCAL { if a.contains_key(*ours) { continue; } - if let Some(v) = num(c, &format!("crs:{crs}")) { + if let Some(v) = get(crs).filter(|v| v.is_finite()) { let lim = if *ours == "exposure" { 4.0 } else if *ours == "color_hue" { @@ -82,6 +89,11 @@ fn adjust(c: &XmpValue) -> Value { a.insert((*ours).into(), json!(v)); } } + a +} + +fn adjust(c: &XmpValue) -> Value { + let mut a = local_values(|crs| num(c, &format!("crs:{crs}"))); let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0); a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0))); Value::Object(a) @@ -98,8 +110,46 @@ fn pt(x: f64, y: f64) -> Value { json!({"x": x, "y": y}) } -/// One mask component → our component JSON; `Err(kind)` names an unsupported one. -fn component(m: &XmpValue, aspect: f64) -> Result { +/// Whether a `Mask/CircularGradient` component puts its effect inside the ellipse, given the +/// container it came from (`crs:Flipped` means different things in different formats). +/// +/// What Lightroom Classic does, from its exports of synthetic DNGs whose masks we wrote (issue #1815): +/// - **`MaskGroupBasedCorrections`, `crs:Version` ≥ 2** (what Lightroom writes today): an ordinary +/// radial — effect inside — is written `Flipped="true"`, so `Flipped` is the "inside" flag. +/// Inverting the mask is a separate field, `crs:MaskInverted` (see [`component`]). `Flipped="false"` +/// is read as outside, the same sense as the legacy filter's; Lightroom never wrote one for us. +/// - **`MaskGroupBasedCorrections` without `crs:Version`**: Lightroom renders the effect inside +/// whatever `Flipped` says, and rewrites both `Flipped="false"` and `"true"` as `Flipped="true"` +/// with `Version="2"`. So `Flipped` is ignored here. +/// - **`CircularGradientBasedCorrections`** (the process-2012 Radial Filter): its default effect was +/// outside, and "Invert Mask" — effect inside — was recorded as `Flipped="true"`. The same "inside" +/// flag as version 2, with outside as the default when it is missing. +fn radial_inside(m: &XmpValue, container: &str) -> bool { + let flipped = flag(m, "crs:Flipped"); + if container == "crs:MaskGroupBasedCorrections" { + if num(m, "crs:Version").is_some_and(|v| v >= 2.0) { flipped.unwrap_or(true) } else { true } + } else { + flipped.unwrap_or(false) + } +} + +/// A `Mask/CircularGradient` component's feather on our 0..100 scale. +/// +/// Lightroom halves `crs:Feather` when it upgrades a `MaskGroupBasedCorrections` radial without +/// `crs:Version` to version 2 (50 → 25, 100 → 50) and renders both the same, so the version-less +/// value is on twice the scale. Version 2's value is used as it is: its 25 and 50 put the middle of +/// Lightroom's fall-off at about 0.87 and 0.76 of the radius, ours at about 0.86 and 0.73. (The +/// shape differs: Lightroom's fall-off straddles the ellipse edge, ours ends at it.) The legacy +/// Radial Filter's value is used as it is: no observation says otherwise. +fn radial_feather(m: &XmpValue, container: &str) -> f64 { + let f = num(m, "crs:Feather").unwrap_or(50.0); + let versionless_group = container == "crs:MaskGroupBasedCorrections" && !num(m, "crs:Version").is_some_and(|v| v >= 2.0); + (if versionless_group { f / 2.0 } else { f }).clamp(0.0, 100.0) +} + +/// One mask component of a correction in `container` → our component JSON; `Err(kind)` names an +/// unsupported one. +fn component(m: &XmpValue, container: &str, aspect: f64) -> Result { let what = text(m, "crs:What").unwrap_or(""); let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 { 1 => "subtract", @@ -123,8 +173,9 @@ fn component(m: &XmpValue, aspect: f64) -> Result { "rx": ((r - l).abs() / 2.0 * sx).max(1e-3), "ry": ((b - t).abs() / 2.0 * sy).max(1e-3), "angle": g("Angle", 0.0), - "feather": g("Feather", 50.0).clamp(0.0, 100.0), - "invert": flag(m, "crs:Flipped").unwrap_or(false), + "feather": radial_feather(m, container), + // our radial's `invert` = effect outside; `crs:MaskInverted` stays the component's + "invert": !radial_inside(m, container), }) } "Mask/Paint" => { @@ -193,7 +244,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec, Vec) { let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default(); let mut parts = Vec::new(); for m in comps { - match component(m, aspect) { + match component(m, container, aspect) { Ok(v) => parts.push(v), Err(kind) => skipped.push(kind), } @@ -392,7 +443,7 @@ mod tests { - + @@ -433,7 +484,8 @@ mod tests { let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); - assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true)); + assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); + assert!(!rad.components[0].invert); let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; assert!(lo < hi && *lo_feather > 0.0); let brush = &s.masks[2]; @@ -491,6 +543,114 @@ mod tests { assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); } + /// One radial correction in `container`, with `attrs` on its mask component (values written + /// for this test; the component attributes follow what Lightroom Classic writes). + fn radial_packet(container: &str, attrs: &str) -> String { + format!( + r#" + + + + + + + + +"# + ) + } + + fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { + let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + assert!(unmapped.is_empty(), "{unmapped:?}"); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.masks.len(), 1, "{:?}", s.masks); + s + } + + /// (the radial's `invert`, its feather, the component's `invert`). + fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { + let c = &s.masks[0].components[0]; + let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; + (*invert, *feather, c.invert) + } + + /// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). + fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { + let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; + let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); + let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); + (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) + } + + #[test] + fn lightroom_radial_flipped_is_inside() { + // as Lightroom Classic writes an ordinary radial mask today (#1815) + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); + // no MaskInverted at all reads the same + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + } + + #[test] + fn lightroom_radial_mask_inverted_is_outside() { + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); + // version 2 with Flipped="false": outside, the legacy sense of the flag + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); + assert_eq!(radial_of(&s), (true, 25.0, false)); + } + + #[test] + fn versionless_group_radial_is_inside_with_half_the_feather() { + // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" + for flipped in ["true", "false"] { + let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); + assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); + } + } + + #[test] + fn legacy_radial_filter_is_outside_unless_flipped() { + // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); + assert_eq!(radial_of(&s), (true, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); + assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); + assert_eq!(radial_of(&s), (false, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); + } + + #[test] + fn lrtemplate_radial_filter_reads_flipped() { + // .lrtemplate presets and catalog develop settings are the same Lua tables + let t = |flipped: &str| { + format!( + r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ + {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ + {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# + ) + }; + for (flipped, outside) in [("true", false), ("false", true)] { + let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); + } + } + /// Spot removal in the shape Lightroom Classic writes it (values written for this test): /// a healed circle with an explicit source, a cloned circle with an automatic source, a /// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 6a89dda88b..431eec7630 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -30,6 +30,16 @@ pub fn load(s: &mut Session) { creator: "LightCraft Demo".into(), ..Default::default() }; + // one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so + // View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file + if sc.name == "Cosmos bloom" { + p.meta.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 }, + mode: Some("AF-S".into()), + area: Some("Flexible Spot".into()), + tracking: None, + }); + } p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8]; p.flag = match sc.id % 6 { 1 => Flag::Pick, @@ -409,7 +419,7 @@ mod tests { #[test] fn a_scene_is_rendered_once_however_many_ask() { use std::sync::atomic::{AtomicUsize, Ordering}; - let (renders, peak) = within(60, || { + let (renders, peak) = within(120, || { let slot = Arc::new(SceneSlot::default()); let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); let render = { @@ -454,7 +464,7 @@ mod tests { /// and that pool's limits; other threads render in parallel on the scene pool. #[test] fn a_rayon_worker_renders_alone_on_its_own_thread() { - let ((how, caller, renderer), (other_how, other_name)) = within(30, || { + let ((how, caller, renderer), (other_how, other_name)) = within(120, || { let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); let slot = SceneSlot::default(); let mut seen = None; @@ -485,7 +495,7 @@ mod tests { /// minutes of CPU time on a small machine). #[test] fn callers_wait_for_the_generation_running() { - let (a, b, renders) = within(30, || { + let (a, b, renders) = within(120, || { let slot = Arc::new(SceneSlot::default()); let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let (started_tx, started) = std::sync::mpsc::channel::<()>(); @@ -518,7 +528,7 @@ mod tests { /// generates the scene again. #[test] fn a_failed_generation_is_an_error_and_the_next_request_tries_again() { - let (first, waiters, again) = within(30, || { + let (first, waiters, again) = within(120, || { let slot = Arc::new(SceneSlot::default()); let (started_tx, started) = std::sync::mpsc::channel::<()>(); let (go, go_rx) = std::sync::mpsc::channel::<()>(); @@ -564,7 +574,7 @@ mod tests { assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded"); assert!(matches!(st.outcome(m), Some(Ok(_)))); // and with threads: a retry racing the waiters of a failed generation never hands them its result - let wrong = within(60, || { + let wrong = within(120, || { let mut wrong = 0; for _ in 0..20 { let slot = Arc::new(SceneSlot::default()); diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index 399d03fde7..366cb5273f 100644 --- a/crates/engine/src/devices.rs +++ b/crates/engine/src/devices.rs @@ -104,6 +104,18 @@ impl DeviceWatch { s.list.clone() } + /// Like [`DeviceWatch::get`], but `None` until the first scan has landed, so an empty list + /// means no cards rather than not looked yet. + pub fn get_scanned(self: &Arc) -> Option> { + let _ = self.get(); + let s = self.state(); + #[cfg(not(target_arch = "wasm32"))] + let scanned = s.scanned.is_some(); + #[cfg(target_arch = "wasm32")] + let scanned = false; + scanned.then(|| s.list.clone()) + } + /// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls. pub fn scan_now(&self) -> Vec { let list = (self.scanner)(); @@ -148,6 +160,12 @@ pub fn devices() -> Vec { global().get() } +/// [`devices`], but `None` until the first scan lands. The import prompt uses it so a card that was +/// already in when LightCraft started isn't taken for a new one. +pub fn devices_scanned() -> Option> { + global().get_scanned() +} + /// Scan the mounted volumes now (blocking), for explicit requests. pub fn devices_now() -> Vec { global().scan_now() @@ -285,4 +303,24 @@ mod tests { assert!(watch.scan_now().is_empty()); assert_eq!(changes.load(Ordering::SeqCst), 2); } + + #[test] + fn get_scanned_waits_for_the_first_scan() { + let gate = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let g = gate.clone(); + let watch = DeviceWatch::new( + move || { + while !g.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(5)); + } + vec![card("NIKON Z 8")] + }, + Duration::from_secs(60), + ); + assert_eq!(watch.get_scanned(), None, "nothing scanned yet"); + assert_eq!(watch.get_scanned(), None); + gate.store(true, Ordering::SeqCst); + assert!(until(|| watch.get_scanned().is_some())); + assert_eq!(watch.get_scanned(), Some(vec![card("NIKON Z 8")])); + } } diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f69..d294ed4d98 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -162,6 +162,40 @@ pub enum Conflict { Skip, } +/// Where a batch's files go (Lightroom Classic's Export To). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DestinationMode { + /// One folder for the whole batch ([`Destination::dir`]). + #[default] + SpecificFolder, + /// Each photo's own folder: the folder its original is in. A photo without one on disk (a + /// demo photo, a photo in the browser's storage) or whose folder is gone gets an error. + SameAsOriginal, +} + +impl DestinationMode { + /// `specificFolder` / `sameAsOriginal` (any case). + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "specificfolder" => Some(Self::SpecificFolder), + "sameasoriginal" => Some(Self::SameAsOriginal), + _ => None, + } + } +} + +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 { @@ -191,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 { @@ -200,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")] @@ -293,58 +350,8 @@ fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) { } } -/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). -static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); - -/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's -/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is -/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's -/// missing-glyph box. -fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec> { - let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); - cjk.sort_by_key(|f| !f.is_mincho()); - std::iter::once(WATERMARK_FONT) - .chain(cjk.into_iter().map(|f| f.bytes)) - .filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok()) - .collect() -} - -/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. -/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too. -fn watermark_cell_glyphs( - font: &ab_glyph::FontRef<'_>, - data: &harfrust::ShaperData, - text: &str, - vertical: bool, - px: f32, - left: f32, - top: f32, -) -> Option> { - use ab_glyph::{Font, ScaleFont}; - let face = harfrust::FontRef::new(font.font_data()).ok()?; - let mut buf = harfrust::UnicodeBuffer::new(); - buf.push_str(text); - buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); - buf.guess_segment_properties(); - let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); - if shaped.glyph_infos().is_empty() { - return None; - } - let sf = font.as_scaled(px); - let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); - let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::(); - let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::(); - let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; - let mut glyphs = Vec::new(); - for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { - let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; - let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); - glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); - x += pos.x_advance as f32 * sx; - y -= pos.y_advance as f32 * sy; - } - Some(glyphs) -} +#[cfg(test)] +use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts}; /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { @@ -369,6 +376,23 @@ fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) { }); } +/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and +/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. +pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { + let (width, height) = (img.width, img.height); + watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { + let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); + if px >= width || py >= height { + return; + } + if let Some(p) = img.data.get_mut(py * width + px) { + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + } + }); +} + /// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float /// images are linear and blend in linear light). pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { @@ -403,7 +427,6 @@ fn watermark_coverage( floor_scale: f32, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), ) { - use ab_glyph::{Font, PxScale, ScaleFont, point}; if !wm.image.trim().is_empty() { logo_coverage(width, height, wm, blend_px); return; @@ -412,97 +435,10 @@ fn watermark_coverage( if text.is_empty() || width == 0 || height == 0 { return; } - let fonts = watermark_fonts(craft); - let Some(latin) = fonts.first() else { return }; - let vertical_data: Vec<_> = if wm.vertical { - fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect() - } else { - Vec::new() - }; let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale); - let mut glyphs = Vec::new(); - let mut x = 0.0f32; - let mut y = 0.0f32; - let mut tw = 0.0f32; - let mut prev = None; - let columns = text.split('\n').count(); - let mut column = 0usize; - use unicode_segmentation::UnicodeSegmentation; - // Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes. - let cells: Box> = - if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; - for cell in cells { - let Some(original) = cell.chars().next() else { continue }; - if cell == "\n" || (wm.vertical && cell == "\r\n") { - if wm.vertical { - column = column.saturating_add(1); - tw = tw.max(y); - y = 0.0; - } else { - tw = tw.max(x); - x = 0.0; - y += px; - } - prev = None; - continue; - } - let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60); - if wm.vertical && (cjk || cell.chars().count() > 1) { - let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| { - let data = data.as_ref()?; - let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?; - let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original)); - if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') { - return None; - } - Some((face, cluster)) - }); - if let Some((face, cluster)) = shaped { - glyphs.extend(cluster.into_iter().map(|g| (face, g, None))); - y += px; - continue; - } - } - let vertical_form = match original { - '、' => '︑', - '。' => '︒', - '(' => '︵', - ')' => '︶', - '「' => '﹁', - '」' => '﹂', - '『' => '﹃', - '』' => '﹄', - '【' => '︻', - '】' => '︼', - '…' => '︙', - other => other, - }; - let has = |font: &ab_glyph::FontRef<'_>, c: char| font.glyph_id(c).0 != 0; - let ch = if wm.vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original }; - // The first face with the glyph; Inter (its missing-glyph box) when none has it. - let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0); - let sf = fonts.get(face).unwrap_or(latin).as_scaled(PxScale::from(px)); - let id = sf.glyph_id(ch); - if wm.vertical { - let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); - let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); - glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); - y += px; - } else { - if let Some((previous_face, previous)) = prev - && previous_face == face - { - x += sf.kern(previous, id); - } - glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); - x += sf.h_advance(id); - prev = Some((face, id)); - } - } - let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; + let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; + let (tw, th) = (layout.width, layout.height); // laid out unturned in a tw × th box, placed by the box it becomes let turns = wm.quarter_turns(); let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) }; @@ -520,35 +456,29 @@ fn watermark_coverage( BottomLeft | Bottom | BottomRight => h - iy - bh, }; let alpha = wm.opacity.clamp(0.0, 1.0); - let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| { - if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { - return; - } - blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col); - }; let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { - for (face, g, rotation) in &glyphs { - if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { - let b = o.px_bounds(); - o.draw(|gx, gy, cov| { - let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); - let (sx, sy) = match rotation { - Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), - None => (sx, sy), - }; - // turned with the box; the shadow still falls down and to the right - let (sx, sy) = turn(turns, sx, sy, tw, th); - blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha); - }); + let mut put = |gx: i32, gy: i32, cov: f32| { + if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { + return; } + blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); + }; + if turns == 0 { + layout.draw_in(ox + off, oy + off, w, h, &mut put); + } else { + // turned with the box; the shadow still falls down and to the right + layout.draw_points(|x, y, cov| { + let (x, y) = turn(turns, x, y, tw, th); + put((ox + off + x) as i32, (oy + off + y) as i32, cov); + }); } } } /// A watermark graphic, decoded once per file (and again when the file changes). -fn watermark_logo(path: &str) -> Option> { +pub(crate) fn watermark_logo(path: &str) -> Option> { use std::collections::HashMap; use std::sync::{Arc, Mutex}; static CACHE: Mutex)>>> = Mutex::new(None); @@ -560,12 +490,19 @@ fn watermark_logo(path: &str) -> Option> { { return Some(img.clone()); } - let bytes = std::fs::read(path).ok()?; - let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8()); + let img = Arc::new(read_graphic(path).ok()?); map.insert(path.to_string(), (modified, img.clone())); Some(img) } +/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the +/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded. +pub fn read_graphic(path: &str) -> Result { + let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; + Ok(decoded.to_srgb8()) +} + /// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to /// `image_width` of the photo, placed like the text, blended with its alpha × opacity. fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { @@ -634,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)] @@ -682,6 +631,8 @@ pub struct ExportOptions { pub naming: String, /// First `{seq}` value. pub start_number: u32, + /// A specific folder for the whole batch, or each photo's own folder. + pub destination_mode: DestinationMode, /// Created inside the destination folder (empty = none). pub subfolder: String, /// When a file of the same name exists in the destination folder. @@ -734,6 +685,7 @@ impl Default for ExportOptions { sharpen_amount: SharpenAmount::Standard, naming: "{name}".into(), start_number: 1, + destination_mode: DestinationMode::SpecificFolder, subfolder: String::new(), conflict: Conflict::Unique, tiff_compression: TiffCompression::Deflate, @@ -776,6 +728,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "sharpenAmount", "naming", "startNumber", + "destinationMode", "subfolder", "conflict", "tiffCompression", @@ -907,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; @@ -962,17 +919,18 @@ 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"])?, "dngCompression" => { serde_json::from_value::(v.clone()) - .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?; + .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"))); @@ -995,7 +953,7 @@ impl ExportOptions { } /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). - fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { + pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { use serde_json::Value; let bad = |msg: String| crate::cmd::bad(cmd, msg); let obj = match v { @@ -1074,12 +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, @@ -1087,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() }), @@ -1608,6 +1567,9 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI pub struct PreparedExport { pub photo: lightcraft_catalog::PhotoId, pub file_name: String, + /// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None` + /// for a demo photo or one in the browser's storage. + pub source_dir: Option, work: Work, /// The library's originals, which [`run_batch`] never writes over (shared by a batch). guard: std::sync::Arc, @@ -1633,6 +1595,9 @@ enum Work { read: Option, packet: String, dng: Option, + /// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size). + quality: u8, + lossy_edge: Option, /// The original must not carry a location ([`removes_location`]). remove_location: bool, label: String, @@ -1667,6 +1632,7 @@ fn prepare_guarded( ) -> Result { let p = session.catalog.photo(id).ok_or("no such photo")?; let file_name = o.file_name_for(p, seq); + let source_dir = original_folder(&p.source); // HDR output only for photos edited in HDR; the rest of the batch exports as usual let sdr_opts; let o = if o.hdr && !p.develop.hdr.enabled { @@ -1702,13 +1668,29 @@ fn prepare_guarded( read: session.media.file_bytes.clone(), packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), dng, + quality: o.quality, + lossy_edge: o.resize.is_some().then(|| { + let (w, h) = output_size(p, o); + u32::try_from(w.max(h)).unwrap_or(u32::MAX) + }), remove_location: removes_location(p, &session.catalog, o), label: p.file_name.clone(), size: (p.width as usize, p.height as usize), }; (work, weight) }; - Ok(PreparedExport { photo: id, file_name, work, guard, weight }) + Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight }) +} + +/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo +/// photo, and on the web, where an original lives in the browser's storage, not in a folder. +fn original_folder(source: &lightcraft_catalog::Source) -> Option { + let lightcraft_catalog::Source::File { path } = source else { return None }; + if cfg!(target_arch = "wasm32") { + return None; + } + let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string(); + (!dir.is_empty()).then_some(dir) } /// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size, @@ -1761,7 +1743,7 @@ impl PreparedExport { }; Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) } - Work::File { path, read, packet, dng, label, size, remove_location } => { + Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => { let bytes = match &read { Some(r) => r(&path)?, None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, @@ -1785,12 +1767,16 @@ impl PreparedExport { if lightcraft_raw::probe(&bytes).is_none() { return Err(format!("{label}: DNG export needs a raw photo")); } - let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); drop(bytes); let compression = match dng { DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, + DngCompression::Lossy => { + lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge } + } }; let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) .map_err(|e| e.to_string())?; @@ -1810,11 +1796,15 @@ pub enum DngCompression { /// Deflate (zip) tiles with prediction. Deflate, Uncompressed, + /// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize + /// setting when there is one (Lightroom's "Use Lossy Compression"). + Lossy, } /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy -/// applied to each file together with its sidecars), or one exact file path (single photo; an -/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar) +/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each +/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file +/// already there is replaced). Either way a catalogued original (or its sidecar) /// is never written over: see [`crate::originals`]. #[derive(Clone, Debug, Default)] pub struct Destination { @@ -2011,12 +2001,12 @@ fn run_batch_with( if !progress(i, &item.file_name) { break; } - let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone()); - placer.place(photo, name, &guard, run_gated(item))?; + let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone()); + placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?; } return Ok(std::mem::take(&mut placer.out)); } - let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect(); + let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect(); let queue = Queue { lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }), cv: std::sync::Condvar::new(), @@ -2031,12 +2021,12 @@ fn run_batch_with( } } let placed = || { - for (i, (photo, name, guard)) in heads.into_iter().enumerate() { + for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() { if !progress(i, &name) { break; } let e = queue.result(i); - placer.place(photo, name, &guard, e)?; + placer.place(photo, name, source_dir.as_deref(), &guard, e)?; } Ok::<_, String>(()) }; @@ -2147,12 +2137,11 @@ impl Queue { } } -/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an -/// original) and its write, in batch order. +/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy, +/// sidecars, never an original) and its write, in batch order. struct Placer<'a> { o: &'a ExportOptions, to: &'a Destination, - dir: String, single: bool, taken: std::collections::HashSet, out: Vec, @@ -2173,29 +2162,53 @@ impl<'a> Placer<'a> { exists: &'a dyn Fn(&str) -> bool, stop_on_error: bool, ) -> Placer<'a> { - let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) }; - Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } + Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } } - /// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err` - /// only when it failed and `stop_on_error` is set. + /// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or + /// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no + /// folder of its own, or whose folder is gone: never a file written somewhere else instead. + fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result { + let base = match self.o.destination_mode { + DestinationMode::SpecificFolder => self.to.dir.clone(), + DestinationMode::SameAsOriginal => { + let Some(d) = source_dir else { + return Err(format!( + "{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder" + )); + }; + if !(self.exists)(d) { + return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder")); + } + d.to_string() + } + }; + Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) }) + } + + /// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it + /// and record the outcome. `Err` only when it failed and `stop_on_error` is set. fn place( &mut self, photo: lightcraft_catalog::PhotoId, name: String, + source_dir: Option<&str>, guard: &crate::originals::OriginalGuard, e: Result, ) -> Result<(), String> { use serde_json::json; - let (o, dir, exists) = (self.o, &self.dir, self.exists); - let e = match e { - Ok(e) => e, + // an exact file path needs no folder + let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) }; + let (o, exists) = (self.o, self.exists); + let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) { + Ok(v) => v, Err(err) if self.stop_on_error => return Err(err), Err(err) => { self.out.push(json!({"photo": photo.0, "file": name, "error": err})); return Ok(()); } }; + let dir = dir.as_str(); // the exported file and its sidecars let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::>(); let path = match self.to.exact.as_deref().filter(|_| self.single) { @@ -2494,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(); @@ -2566,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"); } } @@ -2590,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() { @@ -3190,6 +3203,87 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own + /// folder; it is checked like the other params and kept in presets and Export with Previous. + #[test] + fn destination_mode_param() { + use serde_json::json; + assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder); + for (v, want) in [ + ("sameAsOriginal", DestinationMode::SameAsOriginal), + ("SameAsOriginal", DestinationMode::SameAsOriginal), + ("specificFolder", DestinationMode::SpecificFolder), + ] { + assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}"); + } + let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string(); + assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); + assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); + assert!( + ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})) + .unwrap_err() + .to_string() + .contains("did you mean `destinationMode`") + ); + // saved (a preset, `lastExport`) and read back + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let j = o.to_json(); + assert_eq!(j["destinationMode"], "sameAsOriginal"); + 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] @@ -3272,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/faces_index.rs b/crates/engine/src/faces_index.rs index fd4d9f2e79..bf10e35029 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -55,6 +55,8 @@ pub(crate) struct Index { skipped: std::collections::HashSet, /// The cache file on disk belongs to this model, so new records can be appended to it. file_matches: bool, + /// Records were dropped ([`Index::forget_photo`]): the cache file is written fresh at the next save. + rewrite: bool, pub path: Option, } @@ -87,6 +89,16 @@ impl Index { self.skipped.insert((photo, region_key(rect))); } + /// Drop every face of `photo` (its file was replaced, so they were made from other pixels): they are embedded + /// again, and the cache file is rewritten without them at the next save. + pub fn forget_photo(&mut self, photo: u64) { + let before = self.entries.len(); + self.entries.retain(|k, _| k.0 != photo); + self.unsaved.retain(|(k, _)| k.0 != photo); + self.skipped.retain(|k| k.0 != photo); + self.rewrite |= self.entries.len() != before; + } + #[cfg(test)] pub fn insert(&mut self, photo: u64, rect: &Rect, embedding: Vec) { self.insert_with_view(photo, rect, embedding, rect); @@ -102,7 +114,7 @@ impl Index { /// Whether there are embeddings not yet written to the cache file. pub fn has_unsaved(&self) -> bool { - !self.unsaved.is_empty() + !self.unsaved.is_empty() || self.rewrite } /// Start over for `tag` (`dim` numbers per face), loading what the cache file at `path` holds for it. What the @@ -116,6 +128,7 @@ impl Index { self.unsaved.clear(); self.skipped.clear(); self.file_matches = false; + self.rewrite = false; if let Some(p) = self.path.clone() { self.load(&p); } @@ -175,17 +188,19 @@ impl Index { r } - /// Write what was added since the last save: appended when the file already belongs to this model, else the - /// whole index is written fresh (atomically). A failure is returned, the records stay pending. + /// Write what was added since the last save: appended when the file already belongs to this model, else (or when + /// records were dropped) the whole index is written fresh (atomically). A failure is returned, the records stay + /// pending. pub fn save(&mut self) -> std::io::Result<()> { let Some(path) = self.path.clone() else { self.unsaved.clear(); + self.rewrite = false; return Ok(()); }; - if self.unsaved.is_empty() { + if self.unsaved.is_empty() && !self.rewrite { return Ok(()); } - if self.file_matches { + if self.file_matches && !self.rewrite { let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; let mut buf = Vec::new(); for (k, e) in &self.unsaved { @@ -201,6 +216,7 @@ impl Index { std::fs::write(&part, &buf)?; std::fs::rename(&part, &path)?; self.file_matches = true; + self.rewrite = false; } self.unsaved.clear(); Ok(()) @@ -222,15 +238,31 @@ const SCAN_MAX_FILE: u64 = 64 << 20; /// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft /// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to). -#[derive(Default)] +/// The focus-point backfill keeps its own record the same way ([`Scanned::new`] with its magic and decoder tag). pub(crate) struct Scanned { set: std::collections::HashSet, unsaved: Vec, path: Option, file_matches: bool, + /// A photo was taken out ([`Scanned::remove`]): the record is written fresh at the next save. + rewrite: bool, + magic: [u8; 4], + /// What made the record: a file made by anything else is started afresh. + tag: String, +} + +impl Default for Scanned { + fn default() -> Self { + Scanned::new(*SCAN_MAGIC, SCAN_TAG) + } } impl Scanned { + /// An empty record (in memory until [`Scanned::reset`] gives it a path) whose file starts with `magic` and `tag`. + pub fn new(magic: [u8; 4], tag: &str) -> Scanned { + Scanned { set: Default::default(), unsaved: Vec::new(), path: None, file_matches: false, rewrite: false, magic, tag: tag.to_string() } + } + pub fn path(&self) -> Option<&Path> { self.path.as_deref() } @@ -249,8 +281,16 @@ impl Scanned { } } + /// Search `photo` again (its file was replaced). + pub fn remove(&mut self, photo: u64) { + if self.set.remove(&photo) { + self.unsaved.retain(|p| *p != photo); + self.rewrite = true; + } + } + pub fn has_unsaved(&self) -> bool { - !self.unsaved.is_empty() + !self.unsaved.is_empty() || self.rewrite } /// Start over with the record at `path` (written first: what was not yet saved is not lost). @@ -259,18 +299,20 @@ impl Scanned { self.set.clear(); self.unsaved.clear(); self.file_matches = false; + self.rewrite = false; self.path = path; if let Some(p) = self.path.clone() { self.load(&p); } } - fn header() -> Vec { + fn header(&self) -> Vec { let mut h = Vec::new(); - h.extend_from_slice(SCAN_MAGIC); + h.extend_from_slice(&self.magic); h.push(SCAN_VERSION); - h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes()); - h.extend_from_slice(SCAN_TAG.as_bytes()); + let tag = self.tag.as_bytes().get(..usize::from(u16::MAX)).unwrap_or(self.tag.as_bytes()); + h.extend_from_slice(&(tag.len() as u16).to_le_bytes()); + h.extend_from_slice(tag); h } @@ -280,7 +322,7 @@ impl Scanned { return; } let Ok(bytes) = std::fs::read(path) else { return }; - let head = Self::header(); + let head = self.header(); let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return }; self.file_matches = true; // a half-written last id (a crash while appending) is ignored @@ -292,22 +334,24 @@ impl Scanned { pub fn save(&mut self) -> std::io::Result<()> { let Some(path) = self.path.clone() else { self.unsaved.clear(); + self.rewrite = false; return Ok(()); }; - if self.unsaved.is_empty() { + if self.unsaved.is_empty() && !self.rewrite { return Ok(()); } - if self.file_matches { + if self.file_matches && !self.rewrite { let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; let buf: Vec = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect(); f.write_all(&buf)?; } else { - let mut buf = Self::header(); + let mut buf = self.header(); buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes())); let part = path.with_extension("part"); std::fs::write(&part, &buf)?; std::fs::rename(&part, &path)?; self.file_matches = true; + self.rewrite = false; } self.unsaved.clear(); Ok(()) @@ -406,6 +450,43 @@ mod tests { assert_eq!(b.len(), 0, "a non-finite vector is not loaded back"); let _ = std::fs::remove_file(path); } + + /// The faces and the search of a photo whose file was replaced are dropped from the files too, not only from memory: + /// they don't come back at the next launch (issue #1524). + #[test] + fn a_forgotten_photo_stays_forgotten_after_reloading() { + let dir = std::env::temp_dir().join(format!("lc-faces-forget-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin")); + let mut a = Index::default(); + a.reset("m@abc", 2, Some(emb.clone())); + a.insert(1, &rect(0.1), vec![1.0, 0.0]); + a.insert(2, &rect(0.1), vec![0.0, 1.0]); + a.save().unwrap(); + a.forget_photo(1); + assert!(a.has_unsaved()); + a.save().unwrap(); + a.insert(3, &rect(0.1), vec![1.0, 1.0]); + a.save().unwrap(); + let mut b = Index::default(); + b.reset("m@abc", 2, Some(emb)); + assert!(!b.contains(1, &rect(0.1)) && b.contains(2, &rect(0.1)) && b.contains(3, &rect(0.1))); + let mut s = Scanned::default(); + s.reset(Some(scan.clone())); + s.insert(1); + s.insert(2); + s.save().unwrap(); + s.remove(1); + assert!(s.has_unsaved()); + s.save().unwrap(); + s.insert(3); + s.save().unwrap(); + let mut t = Scanned::default(); + t.reset(Some(scan)); + assert!(!t.contains(1) && t.contains(2) && t.contains(3)); + let _ = std::fs::remove_dir_all(&dir); + } } /// What the recognition commands keep between calls: the loaded model and the embeddings made with it. @@ -456,4 +537,12 @@ impl FacesState { self.saved_at = None; self.epoch = self.epoch.wrapping_add(1); } + + /// Photo `id`'s file was replaced by other content: the faces made from the old pixels are dropped and the photo is + /// searched again (issue #1524). What a worker is still making from the old file is dropped when it comes back. + pub fn content_replaced(&mut self, id: lightcraft_catalog::PhotoId) { + self.index.forget_photo(id.0); + self.scanned.remove(id.0); + self.scan_failed.remove(&id.0); + } } diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 81ff6adaed..61dbc25443 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -127,6 +127,17 @@ fn pool_threads_for(cores: usize, pace: Pace, cap: Option) -> usize { cap.map_or(n, |c| n.min(c.max(1))) } +/// How much of the GPU face models may take at `pace`: face model runs at once, and the share of the time it may be +/// busy. Paused or light: one run at a time, busy a quarter of the time (the user is working, and the window renders +/// on the same GPU); normal: two at once, busy most of the time; full: as fast as it goes. +pub(crate) fn gpu_pace(pace: Pace) -> (usize, f32) { + match pace { + Pace::Pause | Pace::Light => (1, 0.25), + Pace::Normal => (2, 0.75), + Pace::Full => (4, 1.0), + } +} + /// One photo's faces to embed (and, if `detect`, to find first). pub(crate) struct Prepared { /// `@` the embeddings are for. @@ -134,6 +145,9 @@ pub(crate) struct Prepared { /// The session's face epoch when this was prepared: another library has been opened since if it differs. pub epoch: u64, pub id: PhotoId, + /// The photo's content key ([`crate::media::content_key`]) when this was prepared: its file was replaced (Reload + /// after an outside edit) since if it differs. + pub content: String, pub job: RenderJob, /// A raw's embedded preview, tried before the render job (much faster than decoding the raw). pub preview: Option<(String, PreviewLoader)>, @@ -150,6 +164,7 @@ pub(crate) struct Done { pub tag: String, pub epoch: u64, pub id: PhotoId, + pub content: String, /// Each face's region, its embedding (`None` when it could not be made) and the box to show it by. pub results: Vec<(Rect, Option>, Rect)>, /// Faces were to be looked for in this photo. @@ -177,7 +192,7 @@ fn face_rect(f: &Face) -> Option { /// Do the work for one photo. Never panics: whatever goes wrong, its faces come back as `None`. pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { - let Prepared { tag, epoch, id, job, preview, todo, detect, detector } = p; + let Prepared { tag, epoch, id, content, job, preview, todo, detect, detector } = p; let fallback = |todo: Vec| todo.into_iter().map(|r| (r, None, r)).collect::>(); let rects = todo.clone(); let run = catch_unwind(AssertUnwindSafe(|| { @@ -197,7 +212,8 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { let img = Rgb { data: &rgb, width: w, height: h }; let (iw, ih) = embedder.input_size(); let scaled = |f: &Face| f.landmarks.map(|(x, y)| (x * w as f32, y * h as f32)); - let mut results: Vec<(Rect, Option>, Rect)> = todo + // every face's crop first, then all of them through the model at once (one GPU dispatch for a group photo) + let mut faces: Vec<(Rect, Option>, Rect)> = todo .into_iter() .map(|rect| { let best = found.iter().map(|f| (overlap(&rect, f), f)).filter(|(o, _)| *o >= MATCH_IOU).max_by(|a, b| a.0.total_cmp(&b.0)); @@ -214,20 +230,18 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { ih, ), }; - let v = crop.and_then(|c| embedder.embed(&c).ok()); // shown by the detector's box where it found this face: every face then has the same tightness let view = best.and_then(|(_, f)| face_rect(f)).unwrap_or(rect); - (rect, v, view) + (rect, crop, view) }) .collect(); // a photo with no face regions: what the detector is sure of becomes its faces, embedded in the same pass let new_faces = if detect { - detected.map(|faces| { + detected.map(|found| { let mut rects = Vec::new(); - for f in faces.iter().filter(|f| f.score >= REGION_SCORE) { + for f in found.iter().filter(|f| f.score >= REGION_SCORE) { let Some(rect) = face_rect(f) else { continue }; - let v = align_to_template(&img, &scaled(f), iw, ih).and_then(|c| embedder.embed(&c).ok()); - results.push((rect, v, rect)); + faces.push((rect, align_to_template(&img, &scaled(f), iw, ih), rect)); rects.push(rect); } rects @@ -235,10 +249,14 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { } else { None }; + let crops: Vec<&[u8]> = faces.iter().filter_map(|(_, c, _)| c.as_deref()).collect(); + let mut embedded = embedder.embed_batch(&crops).into_iter(); + let results: Vec<(Rect, Option>, Rect)> = + faces.into_iter().map(|(rect, crop, view)| (rect, crop.and_then(|_| embedded.next().and_then(Result::ok)), view)).collect(); (results, new_faces) })); let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None)); - Done { tag, epoch, id, results, detect_wanted: detect, found } + Done { tag, epoch, id, content, results, detect_wanted: detect, found } } type Job = (Arc, Prepared); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index e2144eb859..dd246cf848 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -18,7 +18,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string(); let meta = Meta { camera, - lens: m.lens_model.clone().unwrap_or_default(), + // (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers) + lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(), focal_mm: m.focal_length.map(|f| f as f32), aperture: m.f_number.map(|f| f as f32), shutter, @@ -40,6 +41,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), regions: m.regions.clone(), + focus: m.focus.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } @@ -158,10 +160,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); } @@ -266,19 +266,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)) } } @@ -400,12 +424,17 @@ fn load_bytes_now( // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). let info = raw.info(); let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); + // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) + let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or // 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), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = rayon::join( + || crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), lens.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. @@ -475,10 +504,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); @@ -496,6 +526,8 @@ fn load_bytes_now( camera_tone, mattes, local_tone, + // (the catalog knows the original's size: `media::source_info`) + native_long: None, }, )); } @@ -514,17 +546,28 @@ fn load_bytes_now( /// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one /// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the /// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). -struct WbScaleOf { +pub(crate) struct WbScaleOf { own_matrix: bool, relative: bool, /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. legacy_relative: bool, /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. scale_white: Option, + /// The camera's measured matrices, when they give the scale. + camera: Option<&'static lightcraft_raw::spectral::Camera>, +} + +/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does +/// ([`WbScaleOf::dng_white`]). +pub(crate) enum DngWhite { + /// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does. + Matrices(Box), + /// The white the source's scale shows at As Shot. + At(lightcraft_color::Xy), } impl WbScaleOf { - fn new( + pub(crate) fn new( format: lightcraft_raw::RawFormat, color: &lightcraft_raw::ColorData, wb_multipliers: Option<[f32; 3]>, @@ -541,7 +584,23 @@ impl WbScaleOf { None => None, }; let covered = camera.is_some() || presets; - WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white } + WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera } + } + + /// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the + /// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets' + /// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give). + pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite { + if let Some(camera) = self.camera { + let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() }; + if camera.fill(&mut c) { + return DngWhite::Matrices(Box::new(c)); + } + } + if self.relative { + return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); + } + DngWhite::At(self.scale_white.unwrap_or(own)) } /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the @@ -1048,10 +1107,24 @@ impl crate::Session { } #[cfg(test)] -mod tests { +pub(crate) mod tests { /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides /// them; an exported `travel|Italy|Rome` comes back as itself. + #[test] + fn a_lens_without_a_name_is_shown_by_its_range() { + // a Nikon DSLR raw: no LensModel, the range from the maker note + let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; + assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); + let named = lightcraft_meta::Metadata { + lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), + lens_spec: Some([24.0, 70.0, 4.0, 4.0]), + ..Default::default() + }; + assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); + assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); + } + #[test] fn imported_keywords_keep_their_hierarchy() { let m = lightcraft_meta::Metadata { @@ -1125,6 +1198,40 @@ mod tests { assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); } + /// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source + /// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative + /// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets + /// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. + #[test] + fn a_merged_dng_shows_the_white_balance_its_source_shows() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; + let cases = [ + (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), + (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), + (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), + (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), + ]; + for (format, color, mul, meta) in cases { + let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); + let source = WbScaleOf::new(format, &color, Some(mul), &meta); + let t = lightcraft_raw::color::camera_transform_of(&color, own); + let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); + let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); + let shown = source.as_shot(own); + assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); + } + } + /// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec { let head = 160; @@ -1562,7 +1669,7 @@ 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"); @@ -1633,7 +1740,7 @@ mod tests { } /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. - fn cr3_with_preview(w: u32, h: u32) -> Vec { + pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec { let px: Vec = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); @@ -1729,6 +1836,31 @@ 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}; @@ -1913,7 +2045,7 @@ 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(); @@ -2157,10 +2289,13 @@ mod tests { let p = s.catalog.photo(id).unwrap().clone(); let why = p.preview_only.clone().expect("marked preview only"); assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); + // stamped with the version that decided it, so a later version rechecks it (#662) + assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); + assert!(!p.preview_only_stale()); assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); assert!(!p.develops_raw()); // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults - assert_eq!(crate::media::source_info(&p), SourceInfo::default()); + assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); // agents see it @@ -2174,14 +2309,17 @@ mod tests { let mut s = crate::Session::new().with_fs(); s.open_library(&lib, false).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); assert_eq!(r["reloaded"], json!([id.0]), "{r}"); assert!(s.catalog.photo(id).unwrap().develops_raw()); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); // relinking to a file that decodes clears it too let dng = files.join("DSC_0001.dng"); std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); @@ -2400,4 +2538,112 @@ mod tests { assert!(q.rendered.is_ok(), "{:?}", q.rendered.err()); let _ = std::fs::remove_dir_all(&dir); } + + /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs` + /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). + fn sony_arw(model: &str) -> Vec { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + // 3:2, the aspect Sony's lens opcodes are limited to + let (w, h) = (72u32, 48u32); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + 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![14])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens + /// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. + #[test] + fn arw_with_lens_data_imports_with_profile_corrections_on() { + let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = crate::Session::new().with_fs(); + for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { + let path = dir.join(format!("{model}.arw")); + std::fs::write(&path, sony_arw(model)).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); + assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); + assert!(!p.is_edited(), "{model}: as imported"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's + /// opcodes), off when it doesn't. + #[test] + fn rw2_and_dng_defaults_unchanged() { + use lightcraft_catalog::{Photo, PhotoId, Source}; + let s = crate::Session::new(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; + for format in ["RW2", "DNG"] { + for embedded in [None, Some(lens)] { + let name = format!("x.{format}"); + let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); + p.kind = MediaKind::Raw; + p.embedded_lens = embedded; + crate::import::apply_import_defaults(&s, &mut p); + assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); + } + } + } + + /// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read + /// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the + /// correction off keeps it off. + #[test] + fn existing_photo_settings_are_not_rewritten() { + use lightcraft_catalog::{Op, PhotoId}; + let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let import = |s: &mut crate::Session, name: &str| { + let path = files.join(name); + // trailing bytes of its own: not a duplicate of the other file + std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + PhotoId(r["imported"][0].as_u64().unwrap()) + }; + let old = import(&mut s, "old.arw"); + // as a release that didn't read the Sony tables catalogued it + let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); + d.optics.lens_profile = false; + let ops = vec![ + Op::SetEmbeddedLens { id: old, lens: None }, + Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, + ]; + s.commit("x", Op::Batch { ops }).unwrap(); + let off = import(&mut s, "off.arw"); + s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); + let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); + assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + for b in &before { + let p = s.catalog.photo(b.id).unwrap(); + assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); + } + assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/focus_scan.rs b/crates/engine/src/focus_scan.rs new file mode 100644 index 0000000000..6be63e7ebc --- /dev/null +++ b/crates/engine/src/focus_scan.rs @@ -0,0 +1,432 @@ +//! Reading the camera's focus point for photos already in the library (issue #1480). +//! +//! Import reads a photo's focus point ([`lightcraft_meta::focus`]); photos imported before that have none. This job +//! fills them in, cheaply enough to run over a whole library on a spinning disk: it opens only the photos from a body +//! whose focus data is decoded (by the camera the catalog stores, so nothing else is opened), and reads only the few +//! kilobytes of each file that hold the focus data ([`lightcraft_meta::focus::read_header`]: about 32 KB of a 75 MB α1 +//! ARW), one file at a time on one worker thread. +//! +//! - **Commits** go in batches of [`COMMIT_EVERY`] photos (or every [`COMMIT_AFTER`]), as LightCraft's own bookkeeping +//! ([`Session::apply_system`]): no undo step, no sidecar write (the focus point is never written to XMP). Only +//! `meta.focus` changes, and only on a photo that still has none, still in the library at the same path. +//! - **"Looked, found nothing"** (manual focus, a centre-only Multi report) is kept beside the library in +//! `focus-scanned.bin` (photo ids, appended to, headed by the decoder's tag): it is cheap to append, never touches the +//! catalog, and a decoder that learns more bodies has another tag, so its record starts afresh and those photos are +//! looked at again. A photo whose file can't be read (offline drive, file gone or replaced mid-read) is not recorded: +//! it is tried again the next time the library opens. +//! - **Runs** off the UI thread with a row in the activity stack ("Reading focus points", n of N, ✕ stops it). The UI's +//! per-frame `focus.pump` gives it the pace: nothing new while the user drags, types or scrolls, a short rest between +//! files while they are around. +//! - **Starts** by itself once per library opening (from `focus.pump`) when photos are left to look at, and on demand +//! with `photo.readFocusPoints`. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use lightcraft_meta::focus::HeaderRead; +use serde_json::{Value, json}; + +use crate::activity::{Cancel, TaskGuard}; +use crate::faces_index::Scanned; +use crate::{Result, Session}; + +/// The record of photos looked at with nothing found, beside the library. +pub(crate) const RECORD_FILE: &str = "focus-scanned.bin"; +const RECORD_MAGIC: [u8; 4] = *b"LCFP"; +/// Photos committed at once. +pub(crate) const COMMIT_EVERY: usize = 200; +/// What has been read is committed at least this often, so points appear while a long run goes on. +const COMMIT_AFTER: Duration = Duration::from_secs(3); +/// The rest between files while the user is around (`light` pace): their own reads of the same disk go first. +const LIGHT_REST: Duration = Duration::from_millis(40); +/// How often a paused worker looks at its pace again. +const PAUSE_POLL: Duration = Duration::from_millis(50); +/// Results taken in per pump (the rest wait for the next frame). +const MAX_DRAIN: usize = 5000; + +/// The file extensions read (Sony bodies also shoot HEIF, which carries no maker note this decoder reads). +const EXTENSIONS: [&str; 3] = ["arw", "jpg", "jpeg"]; + +/// How hard the worker may work, from what the user is doing (`focus.pump`'s `pace`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum Pace { + Pause = 0, + Light = 1, + Normal = 2, +} + +impl Pace { + pub fn parse(name: Option<&str>) -> Pace { + match name { + Some("pause") => Pace::Pause, + Some("light") => Pace::Light, + _ => Pace::Normal, + } + } +} + +/// The focus point in the file at `path`, read from its header only. +pub fn read_focus(path: &str) -> std::result::Result { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let len = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); + lightcraft_meta::focus::read_header(len, &mut |off, buf| { + file.seek(SeekFrom::Start(off))?; + file.read_exact(buf) + }) + .map_err(|e| format!("{path}: {e}")) +} + +/// What the worker says about one photo. +enum Outcome { + /// The header was read: the point it holds, or none. + Read { id: PhotoId, focus: Option, bytes: u64 }, + /// The file couldn't be read (offline, gone, changed under the read): tried again another time. + Unavailable, +} + +/// What a run did (the last one is kept for `focus.status` and `photo.readFocusPoints {wait}`). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Summary { + pub total: u64, + pub done: u64, + pub found: u64, + pub none: u64, + pub unavailable: u64, + pub bytes: u64, + pub seconds: f64, + pub cancelled: bool, +} + +impl Summary { + fn json(&self) -> Value { + json!({ + "total": self.total, + "done": self.done, + "found": self.found, + "none": self.none, + "unavailable": self.unavailable, + "bytesRead": self.bytes, + "seconds": self.seconds, + "cancelled": self.cancelled, + }) + } +} + +struct Job { + identity: Arc<()>, + cancel: Arc, + pace: Arc, + rx: mpsc::Receiver<(Outcome, String)>, + /// Its row in the activity stack (dropping the job removes it). + guard: TaskGuard, + /// The path each photo was read at: a photo moved or relinked since is left for another run. + paths: HashMap, + pending: Vec<(PhotoId, Option)>, + committed_at: Instant, + started: Instant, + summary: Summary, +} + +impl Drop for Job { + fn drop(&mut self) { + // the worker stops before its next file (and when it can't hand a result over) + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// The backfill's state in the session. +pub(crate) struct FocusScan { + record: Scanned, + /// The library opening the record and the automatic start belong to. + library: Option>, + auto_checked: bool, + job: Option, + last: Option, + /// The pace the UI last gave (a job started between two pumps starts at it). + pace: Pace, +} + +impl Default for FocusScan { + fn default() -> Self { + FocusScan { + record: Scanned::new(RECORD_MAGIC, &lightcraft_meta::focus::decoder_tag()), + library: None, + auto_checked: false, + job: None, + last: None, + pace: Pace::Normal, + } + } +} + +/// Follow the session's library: another one (or none) drops a running job (its results belong to the old library and +/// are read again there next time) and loads that library's record. +fn follow_library(s: &mut Session) { + if s.focus_scan.library.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.library_identity)) { + return; + } + s.focus_scan.job = None; + s.focus_scan.auto_checked = false; + let path = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(RECORD_FILE)); + s.focus_scan.record.reset(path); + s.focus_scan.library = Some(Arc::clone(&s.library_identity)); +} + +/// The photos to read: in the library, no focus point yet, from a body whose focus data is decoded, a raw or JPEG file; +/// with `ids`, those of them; without, every one not already looked at. In path order (neighbours on disk together). +fn candidates(s: &Session, ids: Option<&[PhotoId]>) -> Vec<(PhotoId, String)> { + let wanted = |ph: &lightcraft_catalog::Photo| -> Option<(PhotoId, String)> { + if !ph.in_library() || ph.meta.focus.is_some() || !lightcraft_meta::focus::camera_supported(&ph.meta.camera) { + return None; + } + let Source::File { path } = &ph.source else { return None }; + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + EXTENSIONS.contains(&ext.as_str()).then(|| (ph.id, path.clone())) + }; + let mut out: Vec<(PhotoId, String)> = match ids { + Some(ids) => ids.iter().filter_map(|id| s.catalog.photo(*id)).filter_map(|ph| wanted(ph)).collect(), + None => s.catalog.photos().filter(|ph| !s.focus_scan.record.contains(ph.id.0)).filter_map(|ph| wanted(ph)).collect(), + }; + out.sort_by(|a, b| a.1.cmp(&b.1)); + out.dedup_by_key(|c| c.0); + out +} + +/// Start reading `list` on a worker thread. +fn start(s: &mut Session, list: Vec<(PhotoId, String)>, pace: Pace) -> std::result::Result<(), String> { + let cancel = Arc::new(AtomicBool::new(false)); + let pace = Arc::new(AtomicU8::new(pace as u8)); + let (tx, rx) = mpsc::channel(); + let paths: HashMap = list.iter().cloned().collect(); + let total = list.len() as u64; + let (worker_cancel, worker_pace) = (cancel.clone(), pace.clone()); + std::thread::Builder::new() + .name("focus-points".into()) + .spawn(move || { + for (id, path) in list { + loop { + if worker_cancel.load(Ordering::Relaxed) { + return; + } + if worker_pace.load(Ordering::Relaxed) != Pace::Pause as u8 { + break; + } + std::thread::sleep(PAUSE_POLL); + } + if worker_pace.load(Ordering::Relaxed) == Pace::Light as u8 { + std::thread::sleep(LIGHT_REST); + } + // a decoder bug in one file is that file's problem, not the job's end + let outcome = match crate::guard::catch("reading a focus point", || read_focus(&path)) { + Ok(Ok(r)) => Outcome::Read { id, focus: r.focus, bytes: r.bytes_read }, + Ok(Err(e)) => { + log::debug!("focus point: {e}"); + Outcome::Unavailable + } + Err(_) => Outcome::Unavailable, + }; + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if tx.send((outcome, name)).is_err() { + return; + } + } + }) + .map_err(|e| format!("could not start reading focus points: {e}"))?; + let guard = s.activity.start("focus", "Reading focus points", Cancel::Flag(cancel.clone())); + guard.progress(0, total); + let now = Instant::now(); + s.focus_scan.job = Some(Job { + identity: Arc::clone(&s.library_identity), + cancel, + pace, + rx, + guard, + paths, + pending: Vec::new(), + committed_at: now, + started: now, + summary: Summary { total, ..Default::default() }, + }); + Ok(()) +} + +/// Commit what has been read: the points found (one system op, no undo step, no sidecar write) and the photos with none +/// (to the record). +fn commit(s: &mut Session) { + let Some(job) = s.focus_scan.job.as_mut() else { return }; + let pending = std::mem::take(&mut job.pending); + job.committed_at = Instant::now(); + if !Arc::ptr_eq(&job.identity, &s.library_identity) { + return; + } + let mut ops = Vec::new(); + let mut none = Vec::new(); + for (id, focus) in pending { + let Some(ph) = s.catalog.photo(id) else { continue }; + let same_file = matches!((&ph.source, job.paths.get(&id)), (Source::File { path }, Some(read)) if path == read); + // deleted, moved, relinked or given a point meanwhile: leave it + if !same_file || !ph.in_library() || ph.meta.focus.is_some() { + continue; + } + match focus { + Some(f) => { + let mut meta = ph.meta.clone(); + meta.focus = Some(f); + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + None => none.push(id.0), + } + } + for id in none { + s.focus_scan.record.insert(id); + } + if let Err(e) = s.focus_scan.record.save() { + log::warn!("could not save the list of photos read for focus points: {e}"); + } + if ops.is_empty() { + return; + } + match s.apply_system(Op::Batch { ops }) { + // the focus point is never written to XMP: no sidecar is rewritten for it + Ok(()) => s.skip_auto_write = true, + Err(e) => log::warn!("could not store focus points: {e}"), + } +} + +/// Take in what the worker has read, commit when a batch is due, and end the job when it is done or cancelled. +/// Returns whether a job is still running. +fn step(s: &mut Session, pace: Pace) -> bool { + let Some(job) = s.focus_scan.job.as_mut() else { return false }; + job.pace.store(pace as u8, Ordering::Relaxed); + let mut finished = job.cancel.load(Ordering::Relaxed); + let mut last_name = None; + for _ in 0..MAX_DRAIN { + match job.rx.try_recv() { + Ok((outcome, name)) => { + job.summary.done += 1; + last_name = Some(name); + match outcome { + Outcome::Read { id, focus, bytes } => { + job.summary.bytes = job.summary.bytes.saturating_add(bytes); + if focus.is_some() { + job.summary.found += 1; + } else { + job.summary.none += 1; + } + job.pending.push((id, focus)); + } + Outcome::Unavailable => job.summary.unavailable += 1, + } + } + Err(mpsc::TryRecvError::Empty) => break, + // the worker ended (all read, cancelled, or it could not go on) + Err(mpsc::TryRecvError::Disconnected) => { + finished = true; + break; + } + } + } + finished |= job.summary.done >= job.summary.total; + job.guard.progress(job.summary.done, job.summary.total); + if let Some(name) = last_name { + job.guard.detail(&name); + } + if finished || job.pending.len() >= COMMIT_EVERY || (!job.pending.is_empty() && job.committed_at.elapsed() >= COMMIT_AFTER) { + commit(s); + } + if finished && let Some(job) = s.focus_scan.job.take() { + let mut summary = job.summary.clone(); + summary.cancelled = job.cancel.load(Ordering::Relaxed) && summary.done < summary.total; + summary.seconds = job.started.elapsed().as_secs_f64(); + log::info!( + "focus points: read {} of {} photos ({} found, {} without, {} unavailable, {} KB) in {:.1} s", + summary.done, + summary.total, + summary.found, + summary.none, + summary.unavailable, + summary.bytes / 1024, + summary.seconds + ); + s.focus_scan.last = Some(summary); + return false; + } + true +} + +fn status(s: &Session) -> Value { + let running = s.focus_scan.job.as_ref().map(|j| json!({"done": j.summary.done, "total": j.summary.total, "found": j.summary.found})); + json!({"active": running.is_some(), "running": running, "last": s.focus_scan.last.as_ref().map(Summary::json), "lookedAt": s.focus_scan.record.len()}) +} + +/// `focus.pump {pace?}`: cheap to call every frame. +pub(crate) fn pump(s: &mut Session, p: &Value) -> Result { + follow_library(s); + let pace = Pace::parse(p.get("pace").and_then(Value::as_str)); + s.focus_scan.pace = pace; + if !step(s, pace) && !s.focus_scan.auto_checked { + // once per library opening: start by itself when photos are left to look at (a few KB each) + s.focus_scan.auto_checked = true; + let list = candidates(s, None); + if !list.is_empty() + && let Err(e) = start(s, list, pace) + { + log::warn!("{e}"); + } + } + Ok(status(s)) +} + +/// `photo.readFocusPoints {ids?, wait?}`. +pub(crate) fn read_points(s: &mut Session, p: &Value) -> Result { + const C: &str = "photo.readFocusPoints"; + follow_library(s); + if s.focus_scan.job.is_some() { + return Ok(json!({"queued": 0, "running": true, "status": status(s)})); + } + let ids: Option> = match p.get("ids") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_array() + .ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))? + .iter() + .map(|v| v.as_u64().map(PhotoId).ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))) + .collect::>>()?, + ), + }; + let list = candidates(s, ids.as_deref()); + let queued = list.len(); + if queued == 0 { + return Ok(json!({"queued": 0, "running": false})); + } + let wait = p.get("wait").and_then(Value::as_bool) == Some(true); + start(s, list, if wait { Pace::Normal } else { s.focus_scan.pace }).map_err(|e| crate::cmd::bad(C, e))?; + // a run asked for counts as this library opening's automatic one + s.focus_scan.auto_checked = true; + if !wait { + return Ok(json!({"queued": queued, "running": true})); + } + // a script's call: read them all now (the batches still commit as they come) + while step(s, Pace::Normal) { + std::thread::sleep(Duration::from_millis(5)); + } + let last = s.focus_scan.last.as_ref().map(Summary::json).unwrap_or(Value::Null); + Ok(json!({"queued": queued, "running": false, "last": last})) +} + +#[cfg(test)] +pub(crate) fn record_len(s: &Session) -> usize { + s.focus_scan.record.len() +} + +#[cfg(test)] +pub(crate) fn record_contains(s: &Session, id: PhotoId) -> bool { + s.focus_scan.record.contains(id.0) +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ecb80741e1..9aa847b01f 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -39,7 +39,7 @@ pub const EXTENSIONS: &[&str] = &[ // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", ]; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -310,6 +310,8 @@ pub struct ImportDefaults { pub auto_folder: Option, pub auto_copy: bool, pub auto_album: Option, + /// Open the import review when a camera or memory card is connected. + pub show_on_card: bool, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -380,6 +382,17 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec { /// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound). pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec, bool) { + expand_noting(paths, skip, limits, None) +} + +/// [`expand_within`], handing the listing of every folder that is walked to `note` +/// ([`crate::walk::files_in_noting`]). +pub(crate) fn expand_noting( + paths: &[String], + skip: Option<&Path>, + limits: crate::walk::Limits, + mut note: Option, +) -> (Vec, bool) { let mut out = Vec::new(); let mut truncated = false; for p in paths { @@ -388,7 +401,10 @@ pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk: continue; } if p.is_dir() { - let w = crate::walk::files_in(p, skip, limits, is_supported); + let w = match note.as_mut() { + Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note), + None => crate::walk::files_in(p, skip, limits, is_supported), + }; if w.truncated { log::warn!( "import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)", @@ -470,6 +486,12 @@ pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec Vec> { + let progress = ScanProgress::default(); + probe_all(Some(probe), paths, &progress.done, &progress.cancel) +} + /// Probe `paths` (several at once), counting each in `done`. Stops before the next file once /// `cancel` is set: the files not reached come back as errors. fn probe_all( @@ -630,6 +652,11 @@ impl ScanInput { (input, paths.to_vec()) } + /// The folder a scan never enters (the library's own). + pub(crate) fn skip(&self) -> Option<&Path> { + self.skip.as_deref() + } + /// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one). pub(crate) fn library(&self) -> std::sync::Arc { std::sync::Arc::clone(&self.library) @@ -648,9 +675,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec { /// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it /// has) when `progress.cancel` is set. -pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { - use std::sync::atomic::Ordering::Relaxed; +pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { let files = expand(paths, input.skip.as_deref()); + scan_files(input, files, progress) +} + +/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]). +pub(crate) fn scan_files(mut input: ScanInput, files: Vec, progress: &ScanProgress) -> ScanOutput { + use std::sync::atomic::Ordering::Relaxed; let by_path = library_files(&input.library, &files); let todo: Vec = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); progress.total.store(todo.len(), Relaxed); @@ -1386,7 +1418,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare p.as_shot_wb = info.as_shot_wb; p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; - p.preview_only = info.preview_only.clone(); + p.set_preview_only(info.preview_only.clone()); apply_import_defaults(s, &mut p); if let Some(sc) = &sidecar { crate::sidecar::merge_into(&mut p, sc, &now); @@ -1532,6 +1564,7 @@ impl Session { stages: None, view_cache: None, display: None, + slide: None, preview_cut: None, }; let embedded = match (&self.media.preview_loader, c.kind) { diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs index c92ee27962..06f22be40c 100644 --- a/crates/engine/src/import_move.rs +++ b/crates/engine/src/import_move.rs @@ -128,7 +128,10 @@ pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> { if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() { return None; } - if base == name { + // `IMG_1.cr3.xmp` names `IMG_1.CR3` too: some apps lower-case the photo's extension + let full = base == name + || base.rsplit_once('.').is_some_and(|(s, x)| s == stem && name.rsplit_once('.').is_some_and(|(_, y)| x.eq_ignore_ascii_case(y))); + if full { Some((e.path(), false)) } else if base == stem { Some((e.path(), true)) diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 1894f7189e..6338b3b401 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 7e05fcffd1..8a282ce3e9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -38,6 +38,8 @@ mod faces_index; #[cfg(not(target_arch = "wasm32"))] mod faces_worker; pub mod files; +#[cfg(not(target_arch = "wasm32"))] +mod focus_scan; pub mod fonts; pub mod guard; pub mod import; @@ -55,15 +57,27 @@ pub mod memory; pub mod merge; mod model_download; pub mod originals; +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; pub mod sidecar; +pub mod slide; +pub mod slide_overlay; +pub mod slide_template; +pub mod slide_title; +pub mod slideshow; +pub mod slideshow_export; pub mod smart; pub mod sync; +pub mod text; mod view; pub mod walk; @@ -172,6 +186,9 @@ pub struct Interaction { pub original: Arc, } +/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. +const MAX_FOLLOWED: usize = 256; + /// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one). static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -190,6 +207,21 @@ pub struct Session { /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). visible: Arc<[PhotoId]>, visible_key: Option<(u64, String)>, + /// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop + /// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once. + pub hold_view: bool, + /// The view last worked out, the catalog's revision then, and the photos in it before stacks are arranged. + /// A small edit is followed from it: only the photos the edit touched are asked about again and put at their + /// place in the order ([`Session::visible`]). It is also what a held view holds ([`Session::hold_view`]). + /// Another source, filter or sort is another view, and so is a smart album whose rules were changed. + base: Option<(String, u64, Vec)>, + /// How many times the view followed an edit from the list it had, and how many times it was worked out from + /// every photo (`ui.inspect` → `perf`): what tells a slow library from a slow edit. + pub view_followed: u64, + pub view_worked_out: u64, + /// Tests: work the view out whole every time (what following has to equal). + #[cfg(test)] + never_follow: bool, /// Identifies the current `visible` list: a new value (unique in the process) every time it /// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids. visible_gen: u64, @@ -206,6 +238,9 @@ pub struct Session { /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, + /// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by + /// [`Session::retry_unsaved_sidecars`] once their file is reachable. + pub(crate) unsaved_sidecars: std::collections::BTreeSet, /// AI denoise: the model in use, the photos that have their picture and the work in progress. pub(crate) denoise: denoise::State, /// Where the host keeps face models (one folder each); `None` where there is no file system (the web). @@ -219,6 +254,9 @@ pub struct Session { /// The loaded recognition model and the face embeddings made with it. #[cfg(not(target_arch = "wasm32"))] pub(crate) faces: faces_index::FacesState, + /// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) focus_scan: focus_scan::FocusScan, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -262,6 +300,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. @@ -269,6 +312,11 @@ pub struct Session { /// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]), /// persisted in prefs.json. pub curve_presets: Vec, + /// Mask effect presets (the Masking panel's Effect menu). + pub mask_effects: Vec, + /// The effect a new mask of each kind (`brush`, `linear`…) starts with: the one last applied + /// to a mask of that kind. + pub mask_tool_effects: std::collections::BTreeMap, /// Saved colour-label name sets. pub label_sets: Vec, /// Look-alike signatures by content key (Find Similar). @@ -296,12 +344,33 @@ pub struct Session { /// Background tasks in flight (imports, exports, preview builds…), for the activity stack and /// `activity.list` / `activity.cancel`. pub activity: activity::Activity, + /// The last (or running) slideshow export (`slideshow.export`). + pub slideshow_export: Option>, /// The last (or running) Build Previews. pub preview_build: Option>, /// Develop defaults applied on import (persisted in prefs.json). pub import_defaults: import::ImportDefaults, /// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json). pub cache_mb: u32, + /// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json). + pub slideshow: slideshow::PlaybackSettings, + /// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in + /// prefs.json). + pub slide_layout: slide::SlideSettings, + /// Slideshow overlays (identity plate, rating stars, text overlays, watermark). + pub slide_overlays: slide_overlay::OverlaySettings, + /// Slideshow intro and ending screens (`slideshow.titles`; persisted in prefs.json). + pub slide_titles: slide_title::TitleSettings, + /// Where user slideshow templates are kept (`/slideshow-templates`; + /// [`Session::with_default_slide_templates`]). `None`: the built-in templates only. + pub slide_templates_dir: Option, + /// The slideshow settings before the last template was applied ("Previous settings"; this + /// session only: library preferences have no undo). + pub slide_templates_previous: Option, + /// The templates as last listed. + pub(crate) slide_templates_cache: slide_template::Cache, + /// The background image the slides last rendered (shared by slide jobs: rendered once). + pub slide_backdrop: std::sync::Arc, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). pub smart_previews_dir: Option, /// Untouched Local records of folders not browsed for this many days are forgotten when the @@ -345,6 +414,12 @@ impl Session { selection: Selection::default(), visible: Vec::new().into(), visible_key: None, + hold_view: false, + base: None, + view_followed: 0, + view_worked_out: 0, + #[cfg(test)] + never_follow: false, visible_gen: 0, total: None, undo: Vec::new(), @@ -353,6 +428,7 @@ impl Session { pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, + unsaved_sidecars: Default::default(), denoise: Default::default(), face_models_dir: None, face_downloads: Default::default(), @@ -360,6 +436,8 @@ impl Session { face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + focus_scan: Default::default(), clipboard: None, meta_clipboard: None, browse: None, @@ -382,9 +460,13 @@ 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(), + mask_effects: Vec::new(), + mask_tool_effects: Default::default(), label_sets: Vec::new(), signatures: Default::default(), lut_profiles: Vec::new(), @@ -398,8 +480,17 @@ impl Session { folder_changes: None, activity: Default::default(), preview_build: None, + slideshow_export: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, + slideshow: slideshow::PlaybackSettings::default(), + slide_layout: slide::SlideSettings::default(), + slide_overlays: slide_overlay::OverlaySettings::default(), + slide_titles: slide_title::TitleSettings::default(), + slide_templates_dir: None, + slide_templates_previous: None, + slide_templates_cache: None, + slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, #[cfg(not(target_arch = "wasm32"))] @@ -427,7 +518,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 @@ -506,16 +601,34 @@ impl Session { } fn apply_catalog(&mut self, op: Op) -> Result { - let inv = self.catalog.apply(op.clone())?; + // Retain only cache IDs while the catalog consumes the operation. Cloning an import + // batch here also cloned every photo, although commit already keeps the journal copy. + let mut invalidated = Vec::new(); + // photos whose file was replaced: the faces made from the old pixels go too (issue #1524) + let mut replaced = Vec::new(); let mut pending = vec![&op]; while let Some(op) = pending.pop() { match op { - Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id), - Op::AddPhoto { photo } => self.media.forget(photo.id), + Op::SetContent { id, .. } => { + invalidated.push(*id); + replaced.push(*id); + } + Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::RemovePhoto { id } => invalidated.push(*id), + Op::AddPhoto { photo } => invalidated.push(photo.id), Op::Batch { ops } => pending.extend(ops), _ => {} } } + let inv = self.catalog.apply(op)?; + for id in invalidated { + self.media.forget(id); + } + #[cfg(not(target_arch = "wasm32"))] + for id in replaced { + self.faces.content_replaced(id); + } + #[cfg(target_arch = "wasm32")] + let _ = replaced; Ok(inv) } @@ -541,8 +654,8 @@ impl Session { } /// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background - /// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the - /// redo stack alone. + /// scan, a raw's as-shot white read by a decode, raws rechecked after an update): journaled like any op, but not + /// an undo step, and it leaves the redo stack alone. pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> { let fwd = op.clone(); self.catalog.apply(op)?; @@ -853,6 +966,16 @@ impl Session { pub fn visible(&mut self) -> &[PhotoId] { let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder)); + // What a held view is told apart by: what is shown and how, and for a smart album its rules (rules that were + // changed ask something else). Not the checks of Missing Photos below: they fill the view in, they are + // no other view. + let asks = matches!(self.source, LibrarySource::Picks) + || matches!(self.source, LibrarySource::Album(a) if self.catalog.album(a).is_some_and(|al| al.is_smart())); + let rules = match self.source { + LibrarySource::Album(a) if asks => self.catalog.album(a).map(|al| format!("{:?}", al.smart)).unwrap_or_default(), + _ => String::new(), + }; + let view = format!("{}|{rules}", key.1); if self.source == LibrarySource::Missing && self.media.availability.is_background() { // the view fills in as the background checks find files gone key.1.push_str(&format!("|{}", self.media.availability.generation())); @@ -860,18 +983,58 @@ impl Session { if self.visible_key.as_ref() != Some(&key) { // "in the last N days" rules count back from the session's clock lightcraft_catalog::rules::set_now(Some((self.clock)())); - let mut f = self.source.to_filter(&self.filter, &self.catalog); - if self.source == LibrarySource::Folder { - // no folder chosen: nothing (an empty path matches nothing) - let b = self.browse.clone().unwrap_or_default(); - f.folder = Some(b.path); - f.subfolders = b.subfolders; + let f = self.view_filter(self.source, &self.filter); + // A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's + // rules, a flag, the filter bar), as long as they are still where the view looks: in the library for + // a view that is only a question, else in the album or folder shown. Taking a photo out of an album + // or the library takes it out of the view at once, held or not. + // (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no + // cost for the hold) + let holds = self.hold_view && (asks || self.filter != Filter::default()); + let within = holds.then(|| { + if asks { + self.view_filter(LibrarySource::All, &Filter::default()) + } else if self.source == LibrarySource::RecentlyAdded { + // (how far back it reaches was worked out for `f`: not a second pass over the library) + Filter { imported_from: f.imported_from.clone(), ..Default::default() } + } else { + self.view_filter(self.source, &Filter::default()) + } + }); + // Views whose order or photos are not the query's alone are worked out whole each time: an album in + // its own order, Recently Added (how far back it reaches moves with every import), Missing Photos. + let own_order = matches!(self.source, LibrarySource::Album(a) if self.sort.key == lightcraft_catalog::SortKey::CaptureDate + && self.filter == Filter::default() + && self.catalog.album(a).is_some_and(|al| !al.is_smart())); + let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing); + // A view that asks about "the last N days" (itself, or through a smart album it looks into) changes + // with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole. + let clocked = f.depends_on_now() + || (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now))); + let base = self.base.take().filter(|(of, _, _)| *of == view); + // A small edit to the same view: the photos it touched are taken out, asked about again, and put back + // at their place in the order. The others neither stopped matching nor moved. + #[cfg(test)] + let clocked = clocked || self.never_follow; + let followed = base.as_ref().filter(|_| !whole && !clocked).and_then(|(_, revision, ids)| { + let touched = self.catalog.touched_since(*revision).filter(|touched| touched.len() <= MAX_FOLLOWED)?; + Some(self.catalog.follow(ids, &touched, &f, &self.sort, within.as_ref())) + }); + match followed { + Some(_) => self.view_followed += 1, + None => self.view_worked_out += 1, } - if self.source == LibrarySource::LibraryFolder { - // no folder chosen: nothing (`.` names no folder) - f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); - } - let mut visible = self.catalog.query(&f, &self.sort); + let mut visible = match (followed, &base, &within) { + (Some(ids), _, _) => ids, + (None, Some((_, _, held)), Some(within)) => { + let keep: std::collections::HashSet = held.iter().copied().collect(); + self.catalog.query_keeping(&f, &self.sort, &keep, within) + } + _ => self.catalog.query(&f, &self.sort), + }; + // what the view shows now is what the next edit is followed from, and what it holds until it is + // refreshed (taken before stacks are arranged, so a photo folded into a stack is held with it) + self.base = (!whole || holds).then(|| (view, self.catalog.revision, visible.clone())); if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() { // the shown folder lost its last photo (deleted, moved, removed): everything, not // an empty grid under the name of a folder that is gone from the sidebar @@ -886,8 +1049,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(); } @@ -921,6 +1088,43 @@ impl Session { &self.visible } + /// `source` as a filter over `base` (the filter bar's, or none), with the folder it shows. + fn view_filter(&self, source: LibrarySource, base: &Filter) -> Filter { + let mut f = source.to_filter(base, &self.catalog); + if source == LibrarySource::Folder { + // no folder chosen: nothing (an empty path matches nothing) + let b = self.browse.clone().unwrap_or_default(); + f.folder = Some(b.path); + f.subfolders = b.subfolders; + } + if source == LibrarySource::LibraryFolder { + // no folder chosen: nothing (`.` names no folder) + f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); + } + f + } + + /// Let go of what a held view kept: the view shows what matches now ([`Session::hold_view`]). + pub fn refresh_view(&mut self) { + self.base = None; + self.visible_key = None; + } + + /// [`Session::visible`] worked out from every photo, whatever was followed before: what following an edit + /// has to equal. + /// Nothing the session keeps is changed by asking: the next edit is followed from where the last one left the + /// view, so a list followed many times over is compared, not only one followed once. + #[cfg(test)] + pub(crate) fn visible_in_full(&mut self) -> Vec { + let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out); + self.never_follow = true; + self.visible_key = None; + let whole = self.visible().to_vec(); + self.never_follow = false; + (self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept; + whole + } + /// Whether the library still holds a photo imported from the folder a `LibraryFolder` source /// shows. fn folder_holds_photos(&self) -> bool { @@ -934,6 +1138,16 @@ impl Session { if self.source == LibrarySource::Missing { return None; } + if self.filter == Filter::default() && !self.hold_view { + // nothing narrows the source: what the view lists is all of it, and is counted already (not a view + // that holds photos that stopped matching: those are not of the source any more) + let _ = self.visible(); + if let Some((_, revision, ids)) = &self.base + && *revision == self.catalog.revision + { + return Some(ids.len()); + } + } let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); if self.total.as_ref().map(|t| &t.0) != Some(&key) { let mut f = self.source.to_filter(&Filter::default(), &self.catalog); @@ -945,7 +1159,8 @@ impl Session { if self.source == LibrarySource::LibraryFolder { f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); } - let n = self.catalog.query(&f, &self.sort).len(); + // (how many, not which: nothing to put in order) + let n = self.catalog.count(&f); self.total = Some((key, n)); } self.total.as_ref().map(|t| t.1) @@ -955,6 +1170,21 @@ impl Session { self.visible().to_vec() } + /// After the source changes, the active photo follows the rule every source shares: it stays + /// when the view shows it, else the view's first photo becomes the active (and only selected) + /// one, or nothing when the view is empty. A view never keeps an active photo it doesn't show + /// (issue #1447: a Local folder kept a library photo active, so Detail and the info showed a + /// photo the grid didn't). + pub fn keep_active_in_view(&mut self) { + let active = self.selection.active; + let vis = self.visible(); + if active.is_some_and(|a| vis.contains(&a)) { + return; + } + let first = vis.first().copied(); + self.selection = first.map(Selection::single).unwrap_or_default(); + } + /// The visible photos without copying them, with their generation: equal generations mean /// the very same list (same catalog revision, source, filter, sort), so a frontend can cache /// whatever it derives from the list under that number. @@ -963,14 +1193,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() } } } @@ -996,6 +1236,8 @@ mod tests; #[cfg(test)] mod tests_album_order; #[cfg(test)] +mod tests_album_remove; +#[cfg(test)] mod tests_albums_local; #[cfg(test)] mod tests_color; @@ -1015,6 +1257,8 @@ mod tests_face_recognize; mod tests_face_tiles; #[cfg(test)] mod tests_filesystem_history; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_focus_scan; #[cfg(test)] mod tests_folders; #[cfg(test)] @@ -1022,6 +1266,8 @@ mod tests_forget_local; #[cfg(test)] mod tests_history_cursor; #[cfg(test)] +mod tests_hold_view; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_import_move; @@ -1034,8 +1280,12 @@ mod tests_library; #[cfg(test)] mod tests_lightroom_folder; #[cfg(test)] +mod tests_mask_effects; +#[cfg(test)] mod tests_merge; #[cfg(test)] +mod tests_move_to_share; +#[cfg(test)] mod tests_organize; #[cfg(test)] mod tests_persist; @@ -1044,14 +1294,24 @@ mod tests_prefs; #[cfg(test)] mod tests_presets_scale; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_process; #[cfg(test)] +mod tests_recheck; +#[cfg(test)] mod tests_segment; #[cfg(test)] mod tests_settings_files; #[cfg(test)] +mod tests_slide; +#[cfg(test)] +mod tests_slideshow_export; +#[cfg(test)] mod tests_spots; #[cfg(test)] mod tests_sync; #[cfg(test)] +mod tests_view_follow; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 0305623f2a..51655aea5a 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -209,12 +209,18 @@ 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. filter_presets: Vec, /// Point-curve presets. curve_presets: Vec, + /// Mask effect presets. + mask_effects: Vec, + /// The effect new masks of each kind start with. + mask_tool_effects: std::collections::BTreeMap, /// Colour-label name sets. label_sets: Vec, /// Imported LUT profiles. @@ -232,6 +238,16 @@ struct PrefsFile { /// Days after which untouched Local records of unbrowsed folders are forgotten (missing = /// the default, 0 = never). forget_local_days: Option, + /// Slideshow playback settings. + slideshow: crate::slideshow::PlaybackSettings, + /// Slideshow slide layout (margins, options, backdrop). + slide_layout: crate::slide::SlideSettings, + /// Slideshow overlays. + slide_overlays: crate::slide_overlay::OverlaySettings, + /// Slideshow intro and ending screens. + slide_titles: crate::slide_title::TitleSettings, + /// A view holds the photos it shows until it is refreshed (`library.holdView`). + hold_view: bool, } fn presets_json(s: &Session) -> String { @@ -322,6 +338,38 @@ impl SettingsLoad { } impl Session { + /// Take over what the thumbnail cache wrote under its name of before (`.jpg` files, which + /// photo galleries took for the person's photos: issues #610, #1066), once for a cache + /// folder that was not made or tidied by this build yet. On a thread of its own, with a row + /// in the activity stack that can stop it: a large cache is some 100 000 files to rename. + fn tidy_thumbnail_cache(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::atomic::{AtomicBool, Ordering}; + let cache = self.media.rendered.clone(); + if !cache.disk().is_some_and(|d| d.needs_tidying()) { + return; + } + let stop = std::sync::Arc::new(AtomicBool::new(false)); + let task = self.activity.start("cache", "Tidying the thumbnail cache", crate::activity::Cancel::Flag(stop.clone())); + let spawned = std::thread::Builder::new().name("lc-thumbs-tidy".into()).spawn(move || { + // the row stays while this runs (it is dropped with the closure, however that ends) + let _row = task; + if let Some(disk) = cache.disk() { + // (also given up when the library is closed or another is opened: the cache + // is retired then, and it is gone on with when this library opens again) + let stopped = || stop.load(Ordering::Relaxed) || cache.generation() == u64::MAX; + let taken = disk.take_over_old(&stopped, &|done, of| _row.progress(done as u64, of as u64)); + log::info!("thumbnail cache: {taken} thumbnails taken over under their new name in {}", disk.dir().display()); + } + }); + // (not started: the thumbnails are still found one by one, and it is tried at the next open) + if let Err(e) = spawned { + log::warn!("thumbnail cache: could not be tidied: {e}"); + } + } + } + /// Check, without closing the library open now, that [`Session::open_library`] can be expected /// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not /// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]). @@ -422,6 +470,8 @@ impl Session { static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32; self.catalog = catalog; + // (nothing of the list the last library showed is this one's) + self.refresh_view(); self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { self.pending_sidecar_repairs.append(&mut entry.retry_sidecars); @@ -443,9 +493,12 @@ 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; + self.mask_effects = prefs.mask_effects; + self.mask_tool_effects = prefs.mask_tool_effects; self.label_sets = prefs.label_sets; self.lut_profiles = prefs.lut_profiles; crate::cmd::lut_profiles::register_all(self); @@ -454,7 +507,13 @@ impl Session { self.recent_keywords = prefs.recent_keywords; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; + self.slideshow = prefs.slideshow.sanitized(); + self.slide_layout = prefs.slide_layout.sanitized(); + self.slide_overlays = prefs.slide_overlays.sanitized(); + self.slide_titles = prefs.slide_titles.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); + self.hold_view = prefs.hold_view; + self.refresh_view(); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { self.media.smart_dir = Some(d.clone()); @@ -504,6 +563,7 @@ impl Session { self.media.clear_sources(); if on_disk { self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes()); + self.tidy_thumbnail_cache(); } let view_written = self.view_json(); self.library = Some(Library { @@ -687,9 +747,12 @@ 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(), + mask_effects: self.mask_effects.clone(), + mask_tool_effects: self.mask_tool_effects.clone(), label_sets: self.label_sets.clone(), lut_profiles: self.lut_profiles.clone(), keyword_sets: self.keyword_sets.clone(), @@ -699,6 +762,11 @@ impl Session { cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), + slideshow: self.slideshow.clone(), + slide_layout: self.slide_layout.clone(), + slide_overlays: self.slide_overlays.clone(), + slide_titles: self.slide_titles.clone(), + hold_view: self.hold_view, }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 2519057a3b..777206bcb8 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -16,7 +16,8 @@ use serde_json::{Value, json}; type Row = BTreeMap; const COMMAND: &str = "library.importLightroom"; -const MAX_FILE: u64 = 1 << 30; +/// Largest catalog database or WAL read into memory (4 GiB). The whole file is held in RAM, so the cap stays finite. +const MAX_FILE: u64 = 1 << 32; fn error(why: impl Into) -> crate::EngineError { crate::EngineError::BadParams { cmd: COMMAND.into(), msg: why.into() } @@ -234,10 +235,14 @@ pub(crate) fn validate_for_job(data: &CatalogImport) -> Result<(), String> { fn bytes(path: &Path) -> Result, String> { use std::io::Read; let f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; - if f.metadata().map_err(|e| e.to_string())?.len() > MAX_FILE { - return Err("catalog or WAL exceeds 1 GiB import limit".into()); + let len = f.metadata().map_err(|e| e.to_string())?.len(); + if len > MAX_FILE { + return Err("catalog or WAL exceeds 4 GiB import limit".into()); } let mut out = Vec::new(); + // Reserve the exact size up front: growing by doubling would briefly need about twice the catalog's size. + let wanted = usize::try_from(len).map_err(|_| "catalog is too large for this platform".to_string())?; + out.try_reserve_exact(wanted).map_err(|_| "not enough memory to read the catalog".to_string())?; f.take(MAX_FILE + 1).read_to_end(&mut out).map_err(|e| e.to_string())?; if out.len() as u64 > MAX_FILE { return Err("catalog grew beyond import limit".into()); @@ -698,14 +703,22 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex let mut ops = Vec::new(); let now = (s.clock)(); let mut missing = Vec::new(); + // Normal import counts probed originals; catalog import also adds offline records, + // distinct originals with identical bytes, and virtual copies. + let mut imported = files.imported.len(); + let mut promoted = HashSet::new(); let mut unmapped = BTreeMap::new(); let mut preserved = 0; // Masters precede their virtual copies. let mut photos: Vec<_> = data.photos.iter().collect(); photos.sort_by_key(|p| number(&p.image, "masterImage") != 0); + let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect(); let mut planned: HashMap = HashMap::new(); for src in photos { let master = number(&src.image, "masterImage"); + if master == 0 && missing_sources.contains(&key(&src.path)) { + missing.push(src.path.clone()); + } let indexed = index .photos .get(&identity(&data, src)) @@ -736,9 +749,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } else { let id = s.catalog.alloc_photo_id(); let name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| src.path.clone()); - if missing_sources.contains(&key(&src.path)) { - missing.push(src.path.clone()); - } let mut p = Photo::new( id, Source::File { path: src.path.clone() }, @@ -807,7 +817,21 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex if keep_develop { (p.develop, p.edited) = previous_develop; } + // Missing files bypass normal-file preparation, including its Local promotion. + // Import still joins the library, and a virtual copy must inherit that membership. + let was_local = p.local; + p.local = false; + let photo_id = p.id; + // Only virtual-copy masters need a second owned photo while operations are staged. + // Keeping every photo here doubled the new-record working set on large catalogs. + if copy_masters.contains(&src.source_id) { + planned.insert(photo_id, p.clone()); + } if s.catalog.photo(p.id).is_some() { + if was_local && promoted.insert(p.id) { + imported += 1; + ops.push(Op::SetLocal { id: p.id, local: false }); + } ops.extend([ Op::SetRating { id: p.id, rating: p.rating }, Op::SetFlag { id: p.id, flag: p.flag }, @@ -817,10 +841,10 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex ]); ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() }); } else { - ops.push(Op::AddPhoto { photo: Box::new(p.clone()) }); + imported += 1; + ops.push(Op::AddPhoto { photo: Box::new(p) }); } - by_path.entry(key(&src.path)).or_insert(p.id); - planned.insert(p.id, p); + by_path.entry(key(&src.path)).or_insert(photo_id); } let mut albums = HashMap::new(); for collection in &data.collections { @@ -893,7 +917,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } @@ -938,12 +962,62 @@ mod tests { warnings: Vec::new(), } } + #[test] + fn offline_local_master_and_copy_join_library_with_one_undo_step() { + let mut s = crate::Session::new(); + let data = sample(); + let master_id = s.catalog.alloc_photo_id(); + let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before"); + master.local = true; + s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap(); + s.undo.clear(); + let before = s.catalog.photo(master_id).unwrap().clone(); + let mut index = ImportIndex::default(); + let missing_sources = [key("/missing/test.ARW")].into_iter().collect(); + let mut import = |s: &mut crate::Session| { + let undo0 = s.undo.len(); + apply_prepared( + s, + data.clone(), + ApplyPrepared { + update_existing: false, + files: Default::default(), + index: &mut index, + archive_path: None, + missing_sources: &missing_sources, + undo0, + }, + ) + .unwrap() + .report + }; + let first = import(&mut s); + assert_eq!(first["mapping"]["1"], master_id.0); + assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos"); + assert_eq!(first["missing"], json!(["/missing/test.ARW"])); + assert_eq!(s.catalog.len(), 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + assert_eq!(s.undo.len(), 1); + let second = import(&mut s); + assert_eq!(second["imported"], 0); + assert_eq!(second["mapping"], first["mapping"]); + s.undo_step().unwrap(); + s.undo_step().unwrap(); + assert_eq!(s.catalog.len(), 1); + assert_eq!(s.catalog.photo(master_id), Some(&before)); + assert!(s.catalog.albums().next().is_none()); + let third = import(&mut s); + assert_eq!(third["imported"], 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + } + #[test] fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() { let mut s = crate::Session::new(); let data = sample(); let r = apply(&mut s, data.clone(), false).unwrap(); assert_eq!(r["photos"], 2); + assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added"); assert_eq!(r["missing"].as_array().unwrap().len(), 1); let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); assert_eq!(master.rating, 5); @@ -1241,6 +1315,10 @@ mod tests { s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap(); let second = apply(&mut s, data, true).unwrap(); assert_eq!(first["mapping"], second["mapping"]); + assert_eq!(first["imported"], 2); + assert_eq!(second["imported"], 0, "reimport updates records instead of adding them"); + assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too"); + assert_eq!(second["library"], dir.to_string_lossy().as_ref()); assert_eq!(s.catalog.len(), 2); assert_eq!(s.catalog.albums().count(), 2); assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56")); @@ -1359,6 +1437,63 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[test] + fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.lrcat"); + let root = dir.join("offline"); + let tables = photo_tables((root.to_str().unwrap(), "")); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a known-missing original must not be probed".into()) + })); + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0); + let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(result.report["imported"], 1); + assert_eq!(result.report["failed"], json!([])); + assert_eq!(result.report["missing"].as_array().unwrap().len(), 1); + let photo = s.catalog.photos().next().unwrap(); + assert_eq!(photo.rating, 5); + assert_eq!((photo.width, photo.height), (20, 10)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn probed_original_is_counted_once_and_reimport_adds_nothing() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id())); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + let source = dir.join("source.lrcat"); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() }) + })); + for expected in [1, 0] { + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(completion.report["imported"], expected); + assert_eq!(completion.report["missing"], json!([])); + assert_eq!(completion.report["failed"], json!([])); + assert_eq!(s.catalog.len(), 1); + } + assert_eq!(probes.load(Ordering::Relaxed), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_root_missing_here_is_found_relative_to_the_catalog() { let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 5e7cb02d4d..8de1cc806d 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -247,23 +247,14 @@ impl LightroomJob { if cancel.load(Ordering::Relaxed) { return Err(cancelled()); } - let paths: Vec = data - .photos - .iter() - .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) - .map(|photo| photo.path.clone()) - .collect(); - let missing_sources = data - .photos - .iter() - .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) - .filter(|photo| !Path::new(&photo.path).is_file()) - .map(|photo| normalize(&photo.path)) - .collect(); - // the catalog step's last unit (validation, and looking for each original), then the photos + let (paths, missing_sources) = original_paths(&data, cancel)?; + // Catalog entries name files, never folders. Missing originals already have enough + // catalog metadata for placeholders; probing them again only adds failed I/O and errors. + // Complete the catalog step before the separate read/add photo progress starts. self.progress.catalog_done.fetch_add(1, Ordering::Relaxed); self.progress.photos.store(true, Ordering::Relaxed); - let normal = self.import.prepare(&paths, cancel); + self.import.total.fetch_add(paths.len(), Ordering::Relaxed); + let normal = self.import.prepare_files(paths, cancel); if cancel.load(Ordering::Relaxed) { normal.rollback(); return Err(cancelled()); @@ -323,6 +314,30 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } }) } +fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(Vec, HashSet)> { + let mut paths = Vec::new(); + let mut missing = HashSet::new(); + let mut seen = HashSet::new(); + for photo in &data.photos { + if cancel.load(Ordering::Relaxed) { + return Err(cancelled()); + } + if lightroom_catalog::number_for_job(&photo.image, "masterImage") != 0 { + continue; + } + let key = normalize(&photo.path); + if !seen.insert(key.clone()) { + continue; + } + if Path::new(&photo.path).is_file() { + paths.push(photo.path.clone()); + } else { + missing.insert(key); + } + } + Ok((paths, missing)) +} + fn normalize(path: &str) -> String { let path = path.replace('\\', "/"); if cfg!(windows) { path.to_lowercase() } else { path } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 0688410ed5..22d76528d6 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 = 62; +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]; @@ -225,9 +225,13 @@ impl DecodedSource { } /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) - /// with any stored camera tone curve. + /// with any stored camera tone curve. The original's size comes from `header` either way: the + /// decoder only sees the level it decoded. pub fn info_or(&self, header: SourceInfo) -> SourceInfo { - self.info.clone().unwrap_or_else(|| SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }) + match &self.info { + Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() }, + None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, + } } } @@ -752,6 +756,8 @@ pub struct RenderJob { pub view_cache: Option<(Arc, Hash128)>, /// Render for this monitor profile ([`Self::with_display`]). pub display: Option>, + /// Compose the render into a slideshow slide ([`Self::with_slide`]). + pub slide: Option>, /// A face tile that can be cut from the raw's embedded preview, tried before `source` /// ([`crate::Session::face_job`]; `None` for every other job). pub preview_cut: Option, @@ -849,7 +855,8 @@ impl RenderJob { /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: /// [`crate::display::present`] converts their sRGB results. pub fn with_display(mut self, display: Option>) -> Self { - if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { + // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) + if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { return self; } let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); @@ -883,7 +890,30 @@ impl RenderJob { } } + /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the + /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the + /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). + pub fn with_slide(mut self, slide: Arc) -> Self { + let k = |s: &Option>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); + self.key ^= k(&self.slide); + self.key ^= k(&Some(slide.clone())); + self.slide = Some(slide); + self + } + + /// [`Self::run_photo`], composed into its slide when this is a slide job (inside [`Self::run`]'s guard). fn run_unguarded(self) -> RenderResult { + let slide = self.slide.clone(); + let mut r = self.run_photo(); + if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { + rendered.image = slide.compose(&rendered.image); + rendered.deep = None; + } + r + } + + /// The photo's render (without slide composition). + fn run_photo(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) { @@ -1131,9 +1161,16 @@ pub fn content_key(p: &Photo) -> String { pub fn source_info(p: &Photo) -> SourceInfo { // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. + // (They are drawn afresh at every size, so they have no original size.) if matches!(p.source, Source::Demo { .. }) { return SourceInfo { raw: true, ..Default::default() }; } + // the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960) + let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0); + SourceInfo { native_long, ..header_info(p) } +} + +fn header_info(p: &Photo) -> SourceInfo { // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white // balance and the display tone curve, like any other rendered file. if p.develops_raw() { @@ -1227,6 +1264,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, preview_cut: None, }) } @@ -1279,6 +1317,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, preview_cut: None, }) } @@ -1332,6 +1371,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, preview_cut, }) } @@ -1525,7 +1565,7 @@ impl crate::Session { /// says and what a smart preview renders with. Only for raws edited in Kelvin. fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { let Some(p) = self.catalog.photo(id) else { return }; - if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) { + if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() { return; } let read = (info.as_shot_temp, info.as_shot_tint); @@ -1720,6 +1760,7 @@ mod thumbnail_hash_tests { file_size: 99, content_hash: Some("replacement".into()), preview_only: None, + preview_checked: String::new(), }) .unwrap(); s.media.forget(PhotoId(1)); @@ -1757,6 +1798,21 @@ mod thumbnail_hash_tests { mod tests { use super::*; + #[test] + fn renders_know_the_original_size_whichever_level_they_come_from() { + // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the + // 512-px thumbnail level denoises like a scaled-down export + let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, ""); + assert_eq!(source_info(&p).native_long, Some(6000)); + let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() }; + let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone())); + let info = thumb.info_or(source_info(&p)); + assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size"); + let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, ""); + assert_eq!(source_info(&unknown).native_long, None); + assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None); + } + #[test] fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { let mut plain = Rgb32f::new(8, 8); @@ -2133,6 +2189,40 @@ mod tests { assert!(mean(&a) > mean(&b) + 10.0, "two stops brighter after: {} vs {}", mean(&a), mean(&b)); } + // LR-VIEW-ZOOM: what the loupe shows at 1:1 is what an export at full size writes. A window of + // the zoomed frame (the photo's own pixels) is pixel-for-pixel the export, at the centre and + // at a corner, with tone, colour and detail edits on a camera-sized photo + #[test] + fn one_to_one_window_pixels_are_export_pixels() { + use lightcraft_pipeline::{OutputDepth, OutputSpace, PixelWindow}; + let mut s = crate::Session::with_demo(); + let id = s.active().unwrap(); + let p = s.catalog.photo(id).unwrap().clone(); + let (w, h) = (p.width as usize, p.height as usize); + assert!(w.max(h) > 2560, "larger than the preview cap: {w}×{h}"); + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = 0.7; + d.light.contrast = 25.0; + d.color.saturation = 20.0; + s.set_develop(id, d, "Edits").unwrap(); + let export = s.render_export(id, w, h, OutputSpace::Srgb, OutputDepth::U8).unwrap().image; + assert_eq!((export.width, export.height), (w, h), "a full-size export"); + for win in [PixelWindow { x: w / 2 - 256, y: h / 2 - 256, w: 512, h: 384 }, PixelWindow { x: 0, y: h - 300, w: 400, h: 300 }] { + let tile = s.region_job(id, w, h, win, true).unwrap().run().rendered.unwrap().image; + assert_eq!((tile.width, tile.height), (win.w, win.h)); + let mut worst = 0u8; + for y in 0..win.h { + for x in 0..win.w { + let (a, b) = (tile.data[y * win.w + x], export.data[(win.y + y) * w + win.x + x]); + for k in 0..3 { + worst = worst.max(a[k].abs_diff(b[k])); + } + } + } + assert!(worst <= 1, "window {win:?}: the 1:1 view differs from the export by up to {worst} levels"); + } + } + /// The thumbnail of a photo whose preview-level source is decoded is cut from it: the file (here /// one nothing can decode) is not read again. #[test] diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ef9fd00f9f..26c69c63d2 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use lightcraft_catalog::{PhotoId, Source}; use lightcraft_develop::DevelopSettings; +use lightcraft_merge::pano::MAX_MEGAPIXELS; use lightcraft_merge::{Deghost, Frame, HdrOptions, PanoOptions, Projection}; use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo}; use lightcraft_raster::{Rgb32f, Rgba8}; @@ -20,6 +21,10 @@ use serde_json::{Value, json}; use crate::{EngineError, Result, Session}; +/// Why `merge.hdrBatch` found nothing to merge. +pub const NO_BRACKET_SETS: &str = + "no bracket sets among these photos: select stacks, or photos taken less than a second apart at different exposures"; + /// Long edge of the frames of a preview merge. pub const PREVIEW_EDGE: usize = 1024; @@ -105,20 +110,18 @@ impl MergeJob { use rayon::prelude::*; let done = std::sync::atomic::AtomicUsize::new(0); let n = self.sources.len(); - let frames: std::result::Result, String> = self - .sources - .par_iter() - .map(|(_, path)| { - let bytes = (self.read)(path)?; - // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) - let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) - .map_err(|e| format!("{path}: {e}")); - let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); - f - }) - .collect(); - frames + let one = |(_, path): &(PhotoId, String)| { + let bytes = (self.read)(path)?; + // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) + let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) + .map_err(|e| format!("{path}: {e}")); + let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); + f + }; + // Full-size photos are decoded one at a time (the demosaic is parallel inside): decoding several 60 MP raws at + // once multiplied the decoders' scratch memory, and a 7-frame bracket ran out of memory. + if self.preview.is_some() { self.sources.par_iter().map(one).collect() } else { self.sources.iter().map(one).collect() } } /// Run the merge. `progress(fraction, stage)` returns false to cancel. @@ -149,8 +152,9 @@ impl MergeJob { MergeKind::Panorama { pano } => { let frames = self.load(true, progress, (0.0, 0.25))?; let p = sub(0.25, 0.9); - let r = lightcraft_merge::stitch(frames, pano, &p).map_err(err)?; - let info = pano_info(&r, &self.sources); + let fit = fit_in_memory(pano, pixels(&frames), crate::memory::total_ram()); + let r = lightcraft_merge::stitch(frames, &fit, &p).map_err(err)?; + let info = pano_info(&r, &self.sources, pano, &fit); ( r.image, r.color, @@ -187,13 +191,15 @@ impl MergeJob { metadata: r.metadata, baseline_exposure: r.baseline_exposure, lens: Vec::new(), + wb_multipliers: r.wb_multipliers, }; f.orient(); merged.push(f); } let p = sub(0.6, 0.9); - let r = lightcraft_merge::stitch(merged, pano, &p).map_err(err)?; - let mut info = pano_info(&r, &self.sources); + let fit = fit_in_memory(pano, pixels(&merged), crate::memory::total_ram()); + let r = lightcraft_merge::stitch(merged, &fit, &p).map_err(err)?; + let mut info = pano_info(&r, &self.sources, pano, &fit); info["bracket"] = json!(b); ( r.image, @@ -230,7 +236,20 @@ impl MergeJob { } } -fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> Value { +/// `asked` is what the command asked for, `fit` what ran (held to memory): when the panorama came +/// out smaller than the photos' own resolution, `limit` says why (`memory`, `size`: Lightroom's +/// 512 MP / 65,000 px, or `asked`). +fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)], asked: &PanoOptions, fit: &PanoOptions) -> Value { + let megapixels = (r.image.width * r.image.height) as f64 / 1e6; + let native = r.native_megapixels; + let why = if fit.max_megapixels < asked.max_megapixels && fit.max_megapixels < native { + "memory" + } else if asked.max_megapixels < MAX_MEGAPIXELS && asked.max_megapixels < native { + "asked" + } else { + "size" + }; + let limit = (megapixels < native * 0.98).then_some(why); json!({ "projection": r.projection, "used": r.used.iter().filter_map(|&i| sources.get(i).map(|s| s.0.0)).collect::>(), @@ -238,9 +257,46 @@ fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> "rmsPx": r.rms, "gains": r.gains, "crop": r.crop, + "megapixels": megapixels, + "nativeMegapixels": native, + "limit": limit, }) } +/// Peak memory of a panorama per pixel of its output (bytes), measured as the peak resident size +/// of synthetic stitches of 20–81 MP from 38–154 MP of photos, rounded up from the smaller ones +/// (the larger need less): while the photos are projected they are still held, beside the tiles +/// ([`PANO_TILE_BYTES`] on top of the photos' own 12 bytes a pixel); the blend that follows, the +/// photos freed, peaks at [`PANO_BLEND_BYTES`]. Writing the DNG takes less than either. +const PANO_TILE_BYTES: f64 = 36.0; +const PANO_BLEND_BYTES: f64 = 68.0; + +/// What a merge may use of the computer's memory: three quarters of it (the rest is the system's +/// and the other apps'), less the app's own memory budget (its caches). +fn merge_memory(ram: Option) -> f64 { + // a computer whose memory we can't read (Windows) is taken to have 8 GB + let ram = ram.unwrap_or(8 << 30) as f64; + (ram * 0.75 - crate::memory::budget() as f64).max(0.0) +} + +/// `pano` with its size held to what fits in memory beside its photos (`frame_px` pixels, 12 +/// bytes each, loaded already): a panorama of five 51 MP photos may have up to 166 MP on a 16 GB +/// computer, 355 MP on 32 GB. Never below 40 megapixels, the size every panorama had before it +/// followed the photos' resolution, and never above what was asked. +pub(crate) fn fit_in_memory(pano: &PanoOptions, frame_px: f64, ram: Option) -> PanoOptions { + let room = merge_memory(ram); + let fits = ((room - frame_px * 12.0) / PANO_TILE_BYTES).min(room / PANO_BLEND_BYTES) / 1e6; + PanoOptions { max_megapixels: pano.max_megapixels.min(fits.max(MIN_FITTED_MEGAPIXELS)), ..pano.clone() } +} + +/// The pixels of `frames`. +fn pixels(frames: &[Frame]) -> f64 { + frames.iter().map(|f| (f.width() * f.height()) as f64).sum() +} + +/// The smallest size [`fit_in_memory`] holds a panorama to (megapixels). +pub(crate) const MIN_FITTED_MEGAPIXELS: f64 = 40.0; + /// Bracket size from EXIF: the smallest period of the exposure sequence (≥ 2). pub fn detect_bracket(frames: &[Frame]) -> Option { let ev: Vec = frames.iter().map(|f| f.exposure).collect::>()?; @@ -331,7 +387,8 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> { boundary_warp: p.get("boundaryWarp").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0), auto_crop: p.get("autoCrop").and_then(Value::as_bool).unwrap_or(false), fill_edges: p.get("fillEdges").and_then(Value::as_bool).unwrap_or(false), - max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).unwrap_or(40.0), + // the photos' own resolution unless asked (held to what fits in memory when it runs) + max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(MAX_MEGAPIXELS).min(MAX_MEGAPIXELS), }; let kind = match id { "merge.hdr" => MergeKind::Hdr { hdr }, diff --git a/crates/engine/src/pdf.rs b/crates/engine/src/pdf.rs new file mode 100644 index 0000000000..963535f5ab --- /dev/null +++ b/crates/engine/src/pdf.rs @@ -0,0 +1,110 @@ +//! A minimal PDF 1.4 writer for image pages: one JPEG XObject per page, explicit byte lengths and +//! a classic cross-reference table. Used by the contact sheet ([`crate::contact_sheet`]) and the +//! slideshow PDF export ([`crate::slideshow_export`]). It streams into any writer, so a long +//! slideshow is never held in memory; the page tree and catalog are written last, once the +//! number of pages is known. + +use std::io::Write; + +/// Objects 1 and 2 are the catalog and the page tree; pages take three objects each from 3. +const FIRST_PAGE: usize = 3; + +pub(crate) struct Pdf { + w: W, + /// Bytes written so far. + pos: usize, + /// Each object's offset, by object number (index 0 unused). + offsets: Vec, + pages: Vec, +} + +fn io(e: std::io::Error) -> String { + format!("PDF: {e}") +} + +impl Pdf { + pub(crate) fn new(w: W) -> Result { + let mut p = Pdf { w, pos: 0, offsets: vec![0; FIRST_PAGE], pages: Vec::new() }; + p.put(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")?; + Ok(p) + } + + /// Bytes written so far. + pub(crate) fn len(&self) -> usize { + self.pos + } + + /// Pages added so far. + pub(crate) fn pages(&self) -> usize { + self.pages.len() + } + + fn put(&mut self, b: &[u8]) -> Result<(), String> { + self.w.write_all(b).map_err(io)?; + self.pos = self.pos.saturating_add(b.len()); + Ok(()) + } + + fn object(&mut self, id: usize, body: &[u8]) -> Result<(), String> { + if self.offsets.len() <= id { + self.offsets.resize(id + 1, 0); + } + if let Some(o) = self.offsets.get_mut(id) { + *o = self.pos; + } + self.put(format!("{id} 0 obj\n").as_bytes())?; + self.put(body)?; + self.put(b"\nendobj\n") + } + + fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) -> Result<(), String> { + let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes(); + body.extend_from_slice(bytes); + body.extend_from_slice(b"\nendstream"); + self.object(id, &body) + } + + /// A page of `points` (width, height) filled by `jpg` (`width × height` pixels, RGB). + /// `extra` goes into the page dictionary (e.g. a display duration and transition). + pub(crate) fn image_page(&mut self, jpg: &[u8], width: usize, height: usize, points: (f64, f64), extra: &str) -> Result<(), String> { + let id = FIRST_PAGE + self.pages.len() * 3; + let (w, h) = points; + self.object( + id, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R{extra} >>", + id + 2, + id + 1 + ) + .as_bytes(), + )?; + self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes())?; + self.stream( + id + 2, + &format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), + jpg, + )?; + self.pages.push(id); + Ok(()) + } + + /// Write the page tree, the catalog (with `catalog_extra` in its dictionary, e.g. a page + /// mode), the cross-reference table and the trailer. + pub(crate) fn finish(mut self, catalog_extra: &str) -> Result { + if self.pages.is_empty() { + return Err("PDF: no pages".into()); + } + let kids = self.pages.iter().map(|i| format!("{i} 0 R")).collect::>().join(" "); + self.object(2, format!("<< /Type /Pages /Count {} /Kids [{kids}] >>", self.pages.len()).as_bytes())?; + self.object(1, format!("<< /Type /Catalog /Pages 2 0 R{catalog_extra} >>").as_bytes())?; + let start = self.pos; + let mut xref = format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).into_bytes(); + for offset in self.offsets.iter().skip(1) { + xref.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + xref.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes()); + self.put(&xref)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } +} diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 3b36316fb5..f1d787ae09 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/presets.rs b/crates/engine/src/presets.rs index c3675e2275..0dee84f3e1 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -155,7 +155,7 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result, String pub fn expand_preset_paths(paths: &[String]) -> Vec { let preset_file = |p: &Path| { let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) + ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack", crate::cmd::mask_effect_files::EFFECT_EXT].contains(&ext.as_str()) }; let mut out = Vec::new(); for p in paths { diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs new file mode 100644 index 0000000000..91e5db69e2 --- /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 0000000000..26d1818e5e --- /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 0000000000..c984cbf547 --- /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 0000000000..37881fbb8f --- /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 0000000000..2465c123ae --- /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 0000000000..562b751297 --- /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 0000000000..abce4061e3 --- /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 0000000000..b9a7d2d376 --- /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/rename.rs b/crates/engine/src/rename.rs index e49355c081..16003cd61c 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -245,6 +245,19 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String { /// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` /// is zero-padded to `seq_width` digits. pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { + expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) +} + +/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text +/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename +/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. +pub fn expand_tokens_with( + template: &str, + p: &Photo, + seq: usize, + seq_width: usize, + extra: &dyn Fn(&str, Option<&str>, &str) -> Option, +) -> String { let (stem, ext) = split_ext(&p.file_name); let mut out = String::new(); let mut rest = template; @@ -257,31 +270,35 @@ pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> }; let tok = &rest[i + 1..i + j]; let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); - let v = match name.trim().to_ascii_lowercase().as_str() { - "name" | "filename" => stem.to_string(), - "num" => { - let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); - stem[stem.len() - digits..].to_string() - } - "seq" | "n" => { - let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); - format!("{seq:0w$}") - } - "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), - "folder" => match &p.source { - Source::File { path } => { - Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + let lower = name.trim().to_ascii_lowercase(); + let v = match extra(&lower, arg, tok) { + Some(v) => v, + None => match lower.as_str() { + "name" | "filename" => stem.to_string(), + "num" => { + let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); + stem[stem.len() - digits..].to_string() } - Source::Demo { .. } => String::new(), + "seq" | "n" => { + let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); + format!("{seq:0w$}") + } + "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), + "folder" => match &p.source { + Source::File { path } => { + Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + } + Source::Demo { .. } => String::new(), + }, + "camera" => p.meta.camera.clone(), + "lens" => p.meta.lens.clone(), + "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), + "rating" => p.rating.to_string(), + "title" => p.meta.title.clone(), + "creator" => p.meta.creator.clone(), + "ext" => ext.to_string(), + _ => format!("{{{tok}}}"), }, - "camera" => p.meta.camera.clone(), - "lens" => p.meta.lens.clone(), - "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), - "rating" => p.rating.to_string(), - "title" => p.meta.title.clone(), - "creator" => p.meta.creator.clone(), - "ext" => ext.to_string(), - _ => format!("{{{tok}}}"), }; out.push_str(&v); rest = &rest[i + j + 1..]; @@ -416,12 +433,31 @@ impl MoveFs for RealFs { } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - use std::io::Write; - let bytes = std::fs::read(a)?; - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?; - f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| { - let _ = std::fs::remove_file(b); - }) + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // Streamed (a large raw or video is never read into memory whole), into a hidden name + // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so + // the partial file can't be removed either, leaves no truncated file under the photo's + // name for a retry to take for the photo. + let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't + // block the next + let partial = (0..1000u32) + .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) + .find(|p| !self.exists(p)) + .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; + let copied = (|| { + let mut from = std::fs::File::open(a)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; + std::io::copy(&mut from, &mut f)?; + f.sync_all() + })(); + let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); + if placed.is_err() { + let _ = std::fs::remove_file(&partial); + } + placed } fn remove_file(&self, p: &Path) -> std::io::Result<()> { diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c67a2c84f5..ea06d69a75 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -63,17 +63,31 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf { } /// An existing sidecar for `original`: the preferred naming first, then the other one (also -/// accepting an upper-case `.XMP`). +/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as +/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss). pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option { + sidecar_candidates(original, naming).find(|p| p.is_file()) +} + +/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`, +/// then under the other naming (the full name also with the photo extension in the other case), +/// each as `.xmp`, `.XMP` and `.Xmp`. +pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator { let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; + let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + let base = Path::new(original).with_extension(""); + let recased = |e: String| (e != ext).then(|| format!("{}.{e}", base.to_string_lossy())); + let full_variants: Vec = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect(); [naming, other] .into_iter() - .flat_map(|n| { - let p = sidecar_path(original, n); - let upper = p.with_extension("XMP"); - [p, upper] + .flat_map(move |n| { + let mut v = vec![sidecar_path(original, n)]; + if n == SidecarNaming::Full { + v.extend(full_variants.iter().map(|o| sidecar_path(o, n))); + } + v }) - .find(|p| p.is_file()) + .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]) } /// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated. @@ -502,10 +516,10 @@ pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Op /// Photo ids touched by an op (for auto-write). pub(crate) fn op_photos(op: &Op, out: &mut Vec) { match op { - Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } => { - if !out.contains(id) { - out.push(*id); - } + Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } + if !out.contains(id) => + { + out.push(*id); } Op::Batch { ops } => ops.iter().for_each(|o| op_photos(o, out)), _ => {} @@ -713,13 +727,61 @@ impl Session { } let owners = StemOwners::among(&self.catalog, &ids); for id in ids { - if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) - && let Err(e) = self.save_sidecar_with(id, &owners) - { - log::warn!("auto-write XMP: {e}"); + if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) { + match self.save_sidecar_with(id, &owners) { + Ok(_) => { + self.unsaved_sidecars.remove(&id); + } + Err(e) => { + // kept for a retry once the file is reachable: a network share that dropped out mustn't leave + // the sidecar silently behind the edit + log::warn!("auto-write XMP: {e}"); + self.unsaved_sidecars.insert(id); + } + } } } } + + /// How many photos have an automatic sidecar write waiting (it failed: the folder offline, read-only or full). + pub fn unsaved_sidecars(&self) -> usize { + self.unsaved_sidecars.len() + } + + /// Write the waiting sidecars whose originals are reachable again (the availability cache answers without + /// touching the disk for the others); photos that are gone, or with auto-write off, are dropped from the wait. + /// Returns how many were written. + pub fn retry_unsaved_sidecars(&mut self) -> usize { + if !self.xmp.auto_write { + self.unsaved_sidecars.clear(); + return 0; + } + let mut ready = Vec::new(); + for id in self.unsaved_sidecars.clone() { + match self.catalog.photo(id).filter(|p| p.copy_of.is_none()).and_then(|p| file_path(p).map(str::to_owned)) { + None => { + self.unsaved_sidecars.remove(&id); + } + Some(path) if self.media.availability.exists(&path) == Some(true) => ready.push(id), + Some(_) => {} + } + } + if ready.is_empty() { + return 0; + } + let owners = StemOwners::among(&self.catalog, &ready); + let mut written = 0; + for id in ready { + match self.save_sidecar_with(id, &owners) { + Ok(_) => { + self.unsaved_sidecars.remove(&id); + written += 1; + } + Err(e) => log::debug!("retry XMP: {e}"), + } + } + written + } } #[cfg(test)] diff --git a/crates/engine/src/slide.rs b/crates/engine/src/slide.rs new file mode 100644 index 0000000000..009a2e2ac3 --- /dev/null +++ b/crates/engine/src/slide.rs @@ -0,0 +1,203 @@ +//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom +//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command), its overlays +//! ([`crate::slide_overlay`], `slideshow.overlays`) and render jobs that compose a photo into its +//! slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]). +//! +//! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings +//! preview and `slideshow.preview` all compose through it, so they draw the same pixels. + +use std::sync::{Arc, Mutex, PoisonError}; + +use lightcraft_catalog::PhotoId; +use lightcraft_raster::Rgba8; +pub use lightcraft_slideshow::{Aspect, Layout, SlideSettings}; + +use crate::media::RenderJob; + +/// The last rendered background image: (its render key, the image). Shared by the slide jobs of +/// a session, so the backdrop photo is rendered once, not once per slide. +pub type BackdropCache = Mutex)>>; + +/// The background image of a slide: a library photo's render job and the shared cache it goes to. +#[derive(Clone)] +pub struct Backdrop { + pub photo: PhotoId, + job: Box, + cache: Arc, +} + +impl Backdrop { + /// The rendered background image (from the cache when it holds this render; otherwise + /// rendered now, on the calling thread). `None` when it can't be rendered (the slide then + /// shows the backdrop colour alone). + pub fn image(&self) -> Option> { + let key = self.job.key; + if let Some((k, img)) = self.cache.lock().unwrap_or_else(PoisonError::into_inner).as_ref() + && *k == key + { + return Some(img.clone()); + } + let img = Arc::new((*self.job).clone().run().rendered.ok()?.image); + *self.cache.lock().unwrap_or_else(PoisonError::into_inner) = Some((key, img.clone())); + Some(img) + } +} + +/// How to turn a photo's render into its slide: the layout, the slide's size in pixels and the +/// background image, if any. +#[derive(Clone)] +pub struct SlideCompose { + pub settings: SlideSettings, + pub width: usize, + pub height: usize, + pub backdrop: Option, + /// The overlays resolved for the slide's photo (`None`: none draw). + pub overlays: Option>, +} + +impl SlideCompose { + /// What makes the slide's pixels differ (for render keys). + pub fn key(&self) -> u64 { + self.settings.pixel_key() + ^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) + ^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f) + ^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29)) + ^ self.overlays.as_ref().map_or(0, |o| o.key().rotate_left(43)) + } + + /// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size). + pub fn compose(&self, photo: &Rgba8) -> Rgba8 { + let bg = self.backdrop.as_ref().and_then(Backdrop::image); + let Some(o) = &self.overlays else { + return lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height); + }; + let (mut img, l) = + lightcraft_slideshow::compose_with(photo, bg.as_deref(), &self.settings, self.width, self.height, &mut |img, l| o.draw_behind(img, l)); + o.draw_front(&mut img, &l); + img + } +} + +impl crate::Session { + /// The aspect (width / height) photo `id` shows at: cropped, straightened, lens-corrected. + pub fn slide_photo_aspect(&self, id: PhotoId) -> Option { + let p = self.catalog.photo(id)?; + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + p.width.max(1) as usize, + p.height.max(1) as usize, + &p.develop, + true, + p.embedded_lens.as_ref(), + ); + let a = frame.aspect(); + Some(if a.is_finite() && a > 0.0 { a } else { 1.5 }) + } + + /// Does a slide show nothing but the photo fitted on black (the plain layout and no + /// overlays)? Playback then shows the photo's own render. + pub fn slides_plain(&self) -> bool { + self.slide_layout.is_plain() && !self.slide_overlays.any() + } + + /// The composition of `settings` at `width × height`, with its background image's render job + /// (the photo `settings.background_image` names, when it is in the catalog) and the library's + /// overlays resolved for `photo`. + pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize, photo: Option) -> Arc { + let overlays = self.slide_overlays.clone(); + self.slide_compose_with(settings, &overlays, width, height, photo) + } + + /// [`Self::slide_compose`] with `overlays` in place of the library's (a template's preview). + pub fn slide_compose_with( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + width: usize, + height: usize, + photo: Option, + ) -> Arc { + let settings = settings.clone().sanitized(); + let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| { + let id = PhotoId(bid); + let aspect = self.slide_photo_aspect(id)?; + let fill = + SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..settings.clone() }; + let (w, h) = lightcraft_slideshow::photo_size(&fill, aspect, width, height); + let job = self.render_job(id, w, h, false, true)?; + Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() }) + }); + let overlays = overlays.any().then(|| { + let p = photo.and_then(|id| self.catalog.photo(id)); + Arc::new(crate::slide_overlay::SlideOverlays::resolve(overlays, p.map(|p| &**p))) + }); + Arc::new(SlideCompose { settings, width, height, backdrop, overlays }) + } + + /// A render job whose result is photo `id`'s slide at `width × height` with the library's + /// slide layout: the photo is rendered at the size its cell needs (its long edge at most + /// `photo_edge`, when given), then composed on the worker. The photo's render is kept as its + /// view preview, like the loupe's. + pub fn slide_job(&mut self, id: PhotoId, width: usize, height: usize, photo_edge: Option) -> Option { + let settings = self.slide_layout.clone(); + self.slide_job_with(&settings, id, width, height, photo_edge, true) + } + + /// [`Self::slide_job`] with `settings` in place of the library's layout; `view` keeps the + /// photo's render as its view preview. + pub fn slide_job_with( + &mut self, + settings: &SlideSettings, + id: PhotoId, + width: usize, + height: usize, + photo_edge: Option, + view: bool, + ) -> Option { + let overlays = self.slide_overlays.clone(); + self.slide_job_look(settings, &overlays, id, width, height, photo_edge, view) + } + + /// [`Self::slide_job_with`] with `overlays` in place of the library's (a template's preview). + #[allow(clippy::too_many_arguments)] + pub fn slide_job_look( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + id: PhotoId, + width: usize, + height: usize, + photo_edge: Option, + view: bool, + ) -> Option { + let aspect = self.slide_photo_aspect(id)?; + let (mut w, mut h) = lightcraft_slideshow::photo_size(settings, aspect, width, height); + if let Some(edge) = photo_edge.filter(|e| *e > 0 && w.max(h) > *e) { + let k = edge as f64 / w.max(h) as f64; + (w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1)); + } + let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? }; + let compose = self.slide_compose_with(settings, overlays, width, height, Some(id)); + Some(job.with_slide(compose)) + } + + /// Photo `id`'s slide at `width × height` with `settings`, rendered now on this thread. + pub fn slide_now(&mut self, settings: &SlideSettings, id: PhotoId, width: usize, height: usize) -> Result { + let overlays = self.slide_overlays.clone(); + self.slide_now_look(settings, &overlays, id, width, height) + } + + /// [`Self::slide_now`] with `overlays` in place of the library's. + pub fn slide_now_look( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + id: PhotoId, + width: usize, + height: usize, + ) -> Result { + let job = self.slide_job_look(settings, overlays, id, width, height, None, false).ok_or("no such photo")?; + let r = job.run(); + self.accept(&r); + r.rendered.map(|r| r.image) + } +} diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs new file mode 100644 index 0000000000..4097393353 --- /dev/null +++ b/crates/engine/src/slide_overlay.rs @@ -0,0 +1,1344 @@ +//! Slideshow overlays (Lightroom Classic's Slideshow ▸ Overlays panel): the identity plate (text +//! or an image file), the photo's rating as stars, text overlays with tokens (file name, title, +//! caption, capture date, exposure, camera, lens, rating…) and the export watermark. +//! [`OverlaySettings`] is a library preference (`prefs.json`), edited with the +//! `slideshow.overlays` command; [`SlideOverlays`] is those settings resolved for one photo (its +//! texts expanded, its rating) and drawn onto the composed slide. +//! +//! **Where the drawing lives:** text needs the fonts (Inter plus the craft-fonts CJK fallbacks, +//! [`crate::text`]) and tokens need the catalog's photo, both in the engine, so overlays are a +//! second pass in the engine over `lightcraft_slideshow::compose_with`'s slide (its `behind` hook +//! draws an identity plate set to "render behind image" before the photo). The composer stays a +//! layout-and-pixels crate with no fonts and no catalog. +//! +//! **Resolution independence:** sizes and offsets are fractions of the slide's shorter side (also +//! for overlays anchored to the photo), so a slide at any size has the same proportions. An +//! overlay is placed at one of nine anchors of the slide or of the photo, moved by its offset +//! (x right, y down) and kept on the slide. +//! +//! **Never crash:** settings are validated by [`OverlaySettings::merged`] and clamped by +//! [`OverlaySettings::sanitized`]; text is cut at [`TEXT_MAX_CHARS`]; an unreadable identity +//! plate image draws nothing (and `slideshow.overlays` reports it); unknown tokens stay as typed +//! (as in rename and export file names) and are reported. + +use std::sync::Arc; + +use lightcraft_catalog::Photo; +use lightcraft_raster::{Image, Plane, Rgba8}; +use lightcraft_slideshow::{Layout, Rect, hex, parse_color}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::export::{Anchor, Watermark}; + +/// Smallest text, plate and star size (fraction of the slide's shorter side). +pub const SIZE_MIN: f64 = 0.005; +/// Largest text line height. +pub const TEXT_SIZE_MAX: f64 = 0.3; +/// Largest identity plate height. +pub const PLATE_SIZE_MAX: f64 = 0.5; +/// Largest star. +pub const STAR_SIZE_MAX: f64 = 0.15; +/// Largest offset either way (fraction of the shorter side). +pub const OFFSET_MAX: f64 = 1.0; +/// Longest shadow offset and radius (fractions of the shorter side). +pub const SHADOW_OFFSET_MAX: f64 = 0.05; +pub const SHADOW_RADIUS_MAX: f64 = 0.05; +/// Most text overlays. +pub const MAX_TEXTS: usize = 16; +/// Longest overlay text or template (characters; longer is cut). +pub const TEXT_MAX_CHARS: usize = 1000; + +/// What an overlay is anchored to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RelativeTo { + #[default] + Slide, + /// The visible photo. + Photo, +} + +impl RelativeTo { + fn id(self) -> &'static str { + match self { + RelativeTo::Slide => "slide", + RelativeTo::Photo => "photo", + } + } +} + +/// The nine anchors, in reading order (for pickers and tests). +pub const ANCHORS: [Anchor; 9] = [ + Anchor::TopLeft, + Anchor::Top, + Anchor::TopRight, + Anchor::Left, + Anchor::Center, + Anchor::Right, + Anchor::BottomLeft, + Anchor::Bottom, + Anchor::BottomRight, +]; + +/// An anchor's id (`topLeft`…). +pub fn anchor_id(a: Anchor) -> &'static str { + match a { + Anchor::TopLeft => "topLeft", + Anchor::Top => "top", + Anchor::TopRight => "topRight", + Anchor::Left => "left", + Anchor::Center => "center", + Anchor::Right => "right", + Anchor::BottomLeft => "bottomLeft", + Anchor::Bottom => "bottom", + Anchor::BottomRight => "bottomRight", + } +} + +/// Where an overlay goes: an anchor of the slide or the photo, and an offset from it. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Placement { + pub anchor: Anchor, + /// Fractions of the slide's shorter side, x to the right, y down. + pub offset_x: f64, + pub offset_y: f64, + pub relative_to: RelativeTo, +} + +impl Default for Placement { + fn default() -> Self { + Placement { anchor: Anchor::BottomRight, offset_x: -0.03, offset_y: -0.03, relative_to: RelativeTo::Slide } + } +} + +impl Placement { + fn sanitized(mut self) -> Self { + self.offset_x = clamp_or(self.offset_x, -OFFSET_MAX, OFFSET_MAX, 0.0); + self.offset_y = clamp_or(self.offset_y, -OFFSET_MAX, OFFSET_MAX, 0.0); + self + } + + fn json(&self, o: &mut Map) { + o.insert("anchor".into(), json!(anchor_id(self.anchor))); + o.insert("offsetX".into(), json!(self.offset_x)); + o.insert("offsetY".into(), json!(self.offset_y)); + o.insert("relativeTo".into(), json!(self.relative_to.id())); + } + + /// Apply a placement key; `false` when `k` isn't one. + fn merge(&mut self, ctx: &str, k: &str, v: &Value) -> Result { + match k { + "anchor" => { + self.anchor = serde_json::from_value(v.clone()).map_err(|_| { + format!("`{ctx}.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, not {v}") + })? + } + "offsetX" => self.offset_x = number(ctx, k, v)?, + "offsetY" => self.offset_y = number(ctx, k, v)?, + "relativeTo" => { + self.relative_to = match v.as_str() { + Some("slide") => RelativeTo::Slide, + Some("photo") => RelativeTo::Photo, + _ => return Err(format!("`{ctx}.relativeTo` must be \"slide\" or \"photo\", not {v}")), + } + } + _ => return Ok(false), + } + Ok(true) + } + + /// The box of a `w × h` overlay on the slide `l`: at the anchor of the slide or the visible + /// photo, moved by the offset, then kept on the slide (when it fits). + pub fn place(&self, l: &Layout, w: f64, h: f64) -> Rect { + let c = match self.relative_to { + RelativeTo::Slide => l.slide, + RelativeTo::Photo => l.visible, + }; + let short = l.slide.width().min(l.slide.height()); + use Anchor::*; + let x = match self.anchor { + TopLeft | Left | BottomLeft => c.x0, + Top | Center | Bottom => (c.x0 + c.x1 - w) / 2.0, + TopRight | Right | BottomRight => c.x1 - w, + } + self.offset_x * short; + let y = match self.anchor { + TopLeft | Top | TopRight => c.y0, + Left | Center | Right => (c.y0 + c.y1 - h) / 2.0, + BottomLeft | Bottom | BottomRight => c.y1 - h, + } + self.offset_y * short; + let s = l.slide; + let fit = |v: f64, lo: f64, hi: f64, len: f64| if v.is_finite() { v.min(hi - len).max(lo) } else { lo }; + let (x, y) = (fit(x, s.x0, s.x1, w), fit(y, s.y0, s.y1, h)); + Rect { x0: x, y0: y, x1: x + w, y1: y + h } + } +} + +/// A soft drop shadow under text. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextShadow { + pub on: bool, + pub opacity: f64, + /// Fraction of the slide's shorter side. + pub offset: f64, + /// Blur radius, fraction of the slide's shorter side. + pub radius: f64, + /// Degrees counter-clockwise from the right (−45 = down right). + pub angle: f64, +} + +impl Default for TextShadow { + fn default() -> Self { + TextShadow { on: false, opacity: 0.6, offset: 0.004, radius: 0.004, angle: -45.0 } + } +} + +/// The identity plate: a line of text, or an image file (PNG / JPEG…, its transparency kept). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct IdentityPlate { + pub enabled: bool, + pub text: String, + /// An image file drawn instead of the text (empty = the text). + pub image: String, + /// Height: the text's line height or the image's height, fraction of the shorter side. + pub size: f64, + pub color: [u8; 3], + pub opacity: f64, + pub place: Placement, + /// Draw it before the photo, so the photo covers it where they meet. + pub behind: bool, +} + +impl Default for IdentityPlate { + fn default() -> Self { + IdentityPlate { + enabled: false, + text: "LightCraft".into(), + image: String::new(), + size: 0.05, + color: [255, 255, 255], + opacity: 0.8, + place: Placement { anchor: Anchor::TopLeft, offset_x: 0.03, offset_y: 0.03, relative_to: RelativeTo::Slide }, + behind: false, + } + } +} + +/// The photo's star rating, drawn as stars. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct RatingStars { + pub enabled: bool, + pub color: [u8; 3], + /// A star's height, fraction of the shorter side. + pub size: f64, + pub place: Placement, +} + +impl Default for RatingStars { + fn default() -> Self { + RatingStars { + enabled: false, + color: [235, 235, 235], + size: 0.03, + place: Placement { anchor: Anchor::TopRight, offset_x: -0.015, offset_y: 0.015, relative_to: RelativeTo::Photo }, + } + } +} + +/// A text overlay: a template with tokens (`{title}`, `{caption}`, `{exposure}`…; see [`TOKENS`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextOverlay { + pub text: String, + /// Line height, fraction of the shorter side. + pub size: f64, + pub color: [u8; 3], + pub opacity: f64, + pub place: Placement, + pub shadow: TextShadow, +} + +impl Default for TextOverlay { + fn default() -> Self { + TextOverlay { + text: "{file}".into(), + size: 0.03, + color: [255, 255, 255], + opacity: 0.9, + place: Placement { anchor: Anchor::Bottom, offset_x: 0.0, offset_y: -0.02, relative_to: RelativeTo::Slide }, + shadow: TextShadow::default(), + } + } +} + +/// Every overlay of a slide. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OverlaySettings { + pub identity_plate: IdentityPlate, + pub rating_stars: RatingStars, + pub texts: Vec, + /// Draw [`Self::watermark`] on the photo. + pub show_watermark: bool, + /// The export watermark's settings (sizes relative to the photo, as in export). + pub watermark: Watermark, +} + +/// Tokens text overlays add to the rename tokens ([`crate::rename::TOKENS`]): tag, meaning. +pub const TOKENS: &[(&str, &str)] = &[ + ("{file}", "File name with its extension"), + ("{caption}", "Caption metadata"), + ("{copyright}", "Copyright metadata"), + ("{exposure}", "Shutter speed and aperture (1/250 s at f/8)"), + ("{shutter}", "Shutter speed"), + ("{aperture}", "Aperture (f/8)"), + ("{focal}", "Focal length (50 mm)"), + ("{date}", "Capture date as YYYY-MM-DD (or {date:%d.%m.%Y} …)"), +]; + +fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if v.is_finite() { v.clamp(lo, hi) } else { fallback } +} + +fn wrap_angle(a: f64, fallback: f64) -> f64 { + if !a.is_finite() { + return fallback; + } + let w = (a + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { 180.0 } else { w } +} + +/// `s` cut at [`TEXT_MAX_CHARS`] characters. +fn cut(s: &mut String) { + if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) { + s.truncate(i); + } +} + +fn number(ctx: &str, k: &str, v: &Value) -> Result { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")), + } +} + +fn boolean(ctx: &str, k: &str, v: &Value) -> Result { + v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}")) +} + +fn string(ctx: &str, k: &str, v: &Value) -> Result { + let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.{k}` must be text, not {v}"))?.to_string(); + cut(&mut s); + Ok(s) +} + +fn colour(ctx: &str, k: &str, v: &Value) -> Result<[u8; 3], String> { + parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")) +} + +fn object<'a>(ctx: &str, v: &'a Value) -> Result<&'a Map, String> { + v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}")) +} + +fn unknown(ctx: &str, k: &str, keys: &str) -> String { + format!("unknown key `{ctx}.{k}` ({keys})") +} + +impl IdentityPlate { + fn sanitized(mut self) -> Self { + let d = IdentityPlate::default(); + cut(&mut self.text); + cut(&mut self.image); + self.size = clamp_or(self.size, SIZE_MIN, PLATE_SIZE_MAX, d.size); + self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity); + self.place = self.place.sanitized(); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("text".into(), json!(self.text)); + o.insert("image".into(), json!(self.image)); + o.insert("size".into(), json!(self.size)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("opacity".into(), json!(self.opacity)); + self.place.json(&mut o); + o.insert("behind".into(), json!(self.behind)); + Value::Object(o) + } + + fn merge(&mut self, v: &Value) -> Result<(), String> { + const C: &str = "identityPlate"; + for (k, v) in object(C, v)? { + if self.place.merge(C, k, v)? { + continue; + } + match k.as_str() { + "enabled" => self.enabled = boolean(C, k, v)?, + "text" => self.text = string(C, k, v)?, + "image" => self.image = string(C, k, v)?.trim().to_string(), + "size" => self.size = number(C, k, v)?, + "color" => self.color = colour(C, k, v)?, + "opacity" => self.opacity = number(C, k, v)?, + "behind" => self.behind = boolean(C, k, v)?, + _ => return Err(unknown(C, k, "enabled, text, image, size, color, opacity, anchor, offsetX, offsetY, relativeTo, behind")), + } + } + Ok(()) + } +} + +impl RatingStars { + fn sanitized(mut self) -> Self { + self.size = clamp_or(self.size, SIZE_MIN, STAR_SIZE_MAX, RatingStars::default().size); + self.place = self.place.sanitized(); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("size".into(), json!(self.size)); + self.place.json(&mut o); + Value::Object(o) + } + + fn merge(&mut self, v: &Value) -> Result<(), String> { + const C: &str = "ratingStars"; + for (k, v) in object(C, v)? { + if self.place.merge(C, k, v)? { + continue; + } + match k.as_str() { + "enabled" => self.enabled = boolean(C, k, v)?, + "color" => self.color = colour(C, k, v)?, + "size" => self.size = number(C, k, v)?, + _ => return Err(unknown(C, k, "enabled, color, size, anchor, offsetX, offsetY, relativeTo")), + } + } + Ok(()) + } +} + +impl TextOverlay { + fn sanitized(mut self) -> Self { + let d = TextOverlay::default(); + cut(&mut self.text); + self.size = clamp_or(self.size, SIZE_MIN, TEXT_SIZE_MAX, d.size); + self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity); + self.place = self.place.sanitized(); + let ds = TextShadow::default(); + let sh = &mut self.shadow; + sh.opacity = clamp_or(sh.opacity, 0.0, 1.0, ds.opacity); + sh.offset = clamp_or(sh.offset, 0.0, SHADOW_OFFSET_MAX, ds.offset); + sh.radius = clamp_or(sh.radius, 0.0, SHADOW_RADIUS_MAX, ds.radius); + sh.angle = wrap_angle(sh.angle, ds.angle); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("text".into(), json!(self.text)); + o.insert("size".into(), json!(self.size)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("opacity".into(), json!(self.opacity)); + self.place.json(&mut o); + o.insert("shadow".into(), json!(self.shadow.on)); + o.insert("shadowOpacity".into(), json!(self.shadow.opacity)); + o.insert("shadowOffset".into(), json!(self.shadow.offset)); + o.insert("shadowRadius".into(), json!(self.shadow.radius)); + o.insert("shadowAngle".into(), json!(self.shadow.angle)); + Value::Object(o) + } + + fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> { + for (k, v) in object(ctx, v)? { + if self.place.merge(ctx, k, v)? { + continue; + } + match k.as_str() { + "text" => self.text = string(ctx, k, v)?, + "size" => self.size = number(ctx, k, v)?, + "color" => self.color = colour(ctx, k, v)?, + "opacity" => self.opacity = number(ctx, k, v)?, + "shadow" => self.shadow.on = boolean(ctx, k, v)?, + "shadowOpacity" => self.shadow.opacity = number(ctx, k, v)?, + "shadowOffset" => self.shadow.offset = number(ctx, k, v)?, + "shadowRadius" => self.shadow.radius = number(ctx, k, v)?, + "shadowAngle" => self.shadow.angle = number(ctx, k, v)?, + _ => { + return Err(unknown( + ctx, + k, + "text, size, color, opacity, anchor, offsetX, offsetY, relativeTo, shadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle", + )); + } + } + } + Ok(()) + } +} + +/// The keys [`OverlaySettings::merged`] takes. +const KEYS: &str = "identityPlate, ratingStars, texts, addText, removeText, showWatermark, watermark"; + +impl OverlaySettings { + /// Every value in range (non-finite numbers take the defaults), at most [`MAX_TEXTS`] texts. + pub fn sanitized(mut self) -> Self { + self.identity_plate = self.identity_plate.sanitized(); + self.rating_stars = self.rating_stars.sanitized(); + self.texts.truncate(MAX_TEXTS); + self.texts = self.texts.into_iter().map(TextOverlay::sanitized).collect(); + let d = Watermark::default(); + let w = &mut self.watermark; + let f = |v: f32, lo: f32, hi: f32, fb: f32| if v.is_finite() { v.clamp(lo, hi) } else { fb }; + w.size = f(w.size, 0.005, 0.5, d.size); + w.opacity = f(w.opacity, 0.0, 1.0, d.opacity); + w.inset_x = f(w.inset_x, 0.0, 0.4, d.inset_x); + w.inset_y = f(w.inset_y, 0.0, 0.4, d.inset_y); + if !matches!(w.rotation, 0 | 90 | 180 | 270) { + w.rotation = 0; + } + w.image_width = f(w.image_width, 0.01, 1.0, d.image_width); + cut(&mut w.text); + w.target = None; + self + } + + /// Does any overlay draw (on some photo)? + pub fn any(&self) -> bool { + let p = &self.identity_plate; + (p.enabled && p.opacity > 0.0 && (!p.text.trim().is_empty() || !p.image.trim().is_empty())) + || self.rating_stars.enabled + || self.texts.iter().any(|t| t.opacity > 0.0 && !t.text.trim().is_empty()) + || (self.show_watermark && (!self.watermark.text.trim().is_empty() || !self.watermark.image.trim().is_empty())) + } + + pub fn to_json(&self) -> Value { + let wm = serde_json::to_value(&self.watermark).unwrap_or(Value::Null); + json!({ + "identityPlate": self.identity_plate.to_json(), + "ratingStars": self.rating_stars.to_json(), + "texts": self.texts.iter().map(TextOverlay::to_json).collect::>(), + "showWatermark": self.show_watermark, + "watermark": wm, + }) + } + + /// These settings with the keys `p` names changed, validated and clamped; a wrong type or an + /// unknown key is an error and nothing changes. `texts` replaces the list (each entry merged + /// onto the one at its index, or a new default text); `addText` appends one (an object, `{}` + /// for the default); `removeText` removes the one at an index; `watermark` takes the export + /// watermark's keys (or text alone) merged onto the current one. + pub fn merged(&self, p: &Value) -> Result { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "identityPlate" => s.identity_plate.merge(v)?, + "ratingStars" => s.rating_stars.merge(v)?, + "texts" => { + let list = v.as_array().ok_or_else(|| format!("`texts` must be a list of text overlays, not {v}"))?; + if list.len() > MAX_TEXTS { + return Err(format!("at most {MAX_TEXTS} text overlays, not {}", list.len())); + } + let mut next = Vec::with_capacity(list.len()); + for (i, item) in list.iter().enumerate() { + let mut t = s.texts.get(i).cloned().unwrap_or_default(); + t.merge(&format!("texts[{i}]"), item)?; + next.push(t); + } + s.texts = next; + } + "addText" => { + if s.texts.len() >= MAX_TEXTS { + return Err(format!("at most {MAX_TEXTS} text overlays")); + } + let mut t = TextOverlay::default(); + t.merge("addText", v)?; + s.texts.push(t); + } + "removeText" => { + let i = v.as_u64().ok_or_else(|| format!("`removeText` must be the index of a text overlay, not {v}"))?; + let i = usize::try_from(i).ok().filter(|i| *i < s.texts.len()).ok_or_else(|| format!("no text overlay {i}"))?; + s.texts.remove(i); + } + "showWatermark" => s.show_watermark = boolean("", k, v).map_err(|_| format!("`showWatermark` must be true or false, not {v}"))?, + "watermark" => { + crate::export::ExportOptions::validate_watermark("slideshow.overlays", v).map_err(|e| e.to_string())?; + s.watermark = match v { + Value::String(t) => Watermark { text: t.clone(), ..s.watermark.clone() }, + _ => { + let mut cur = serde_json::to_value(&s.watermark).map_err(|e| e.to_string())?; + if let (Some(c), Some(o)) = (cur.as_object_mut(), v.as_object()) { + for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { + if k == "inset" { + // the legacy single margin sets whichever of the two the patch leaves out + for axis in ["insetX", "insetY"] { + if !o.contains_key(axis) { + c.insert(axis.into(), v.clone()); + } + } + } else { + c.insert(k.clone(), v.clone()); + } + } + } + serde_json::from_value(cur).map_err(|e| format!("`watermark`: {e}"))? + } + }; + } + other => return Err(format!("unknown setting `{other}` ({KEYS})")), + } + } + Ok(s.sanitized()) + } + + /// A hash of the settings (for render keys). Stable within a run. + pub fn pixel_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.clone().sanitized().to_json().to_string().hash(&mut h); + h.finish() + } + + /// Problems worth telling the user about: an identity plate image that can't be read, tags in + /// text overlays that aren't tokens (they show as typed). + pub fn warnings(&self) -> Vec { + let mut out = Vec::new(); + let p = &self.identity_plate; + if p.enabled && !p.image.trim().is_empty() && crate::export::watermark_logo(p.image.trim()).is_none() { + out.push(format!("identity plate image {} can't be read; nothing is drawn", p.image.trim())); + } + for (i, t) in self.texts.iter().enumerate() { + for tag in unknown_tokens(&t.text) { + out.push(format!("text overlay {}: {tag} isn't a token and shows as typed", i + 1)); + } + } + out + } +} + +/// An exposure as "1/250 s at f/8" (either part alone when the other is unknown). +fn exposure(p: &Photo) -> String { + let parts: Vec = [shutter(p), aperture(p)].into_iter().filter(|s| !s.is_empty()).collect(); + parts.join(" at ") +} + +fn shutter(p: &Photo) -> String { + let s = p.meta.shutter.trim(); + if s.is_empty() || s.ends_with('s') || s.ends_with('"') { s.to_string() } else { format!("{s} s") } +} + +fn aperture(p: &Photo) -> String { + p.meta.aperture.filter(|a| a.is_finite() && *a > 0.0).map(|a| format!("f/{a:.1}").replace(".0", "")).unwrap_or_default() +} + +/// The overlay tokens beyond the rename ones; `{seq}` has no meaning on a slide and stays as typed. +fn overlay_token(p: &Photo, name: &str, arg: Option<&str>, tag: &str) -> Option { + Some(match name { + "file" => p.file_name.clone(), + "caption" => p.meta.caption.clone(), + "copyright" => p.meta.copyright.clone(), + "exposure" => exposure(p), + "shutter" => shutter(p), + "aperture" => aperture(p), + "focal" => p.meta.focal_mm.filter(|f| f.is_finite() && *f > 0.0).map(|f| format!("{f:.0} mm")).unwrap_or_default(), + "date" if arg.is_none() => crate::rename::expand_tokens("{date:%Y-%m-%d}", p, 1, 1), + "seq" | "n" => format!("{{{tag}}}"), + _ => return None, + }) +} + +/// `template`'s tokens replaced for `p`: the rename tokens ([`crate::rename::expand_tokens`]) and +/// [`TOKENS`]. Unknown tags stay as typed. +pub fn expand(template: &str, p: &Photo) -> String { + crate::rename::expand_tokens_with(template, p, 1, 1, &|name, arg, tag| overlay_token(p, name, arg, tag)) +} + +/// The `{…}` tags in `template` that aren't tokens. +pub fn unknown_tokens(template: &str) -> Vec { + let p = crate::rename::sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(after) = rest.get(i..) else { break }; + let Some(j) = after.find('}') else { break }; + let Some(tag) = after.get(..j + 1) else { break }; + if expand(tag, &p) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = after.get(j + 1..).unwrap_or(""); + } + out +} + +/// What an overlay box is, for [`SlideOverlays::boxes`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + IdentityPlate, + RatingStars, + /// The text overlay at this index. + Text(usize), + Watermark, +} + +impl Kind { + pub fn id(&self) -> String { + match self { + Kind::IdentityPlate => "identityPlate".into(), + Kind::RatingStars => "ratingStars".into(), + Kind::Text(i) => format!("text{i}"), + Kind::Watermark => "watermark".into(), + } + } +} + +/// The overlays of one photo's slide: the settings, its texts expanded, its rating. +#[derive(Clone, Debug, PartialEq)] +pub struct SlideOverlays { + pub settings: OverlaySettings, + /// One per text overlay, tokens replaced. + pub texts: Vec, + pub rating: u8, +} + +/// Gap between stars, as a share of a star's size. +const STAR_GAP: f64 = 0.2; + +impl SlideOverlays { + /// `settings` resolved for `photo` (`None`: no photo, so no tokens and no rating). + pub fn resolve(settings: &OverlaySettings, photo: Option<&Photo>) -> SlideOverlays { + let settings = settings.clone().sanitized(); + let texts = settings.texts.iter().map(|t| photo.map(|p| expand(&t.text, p)).unwrap_or_else(|| t.text.clone())).collect(); + SlideOverlays { settings, texts, rating: photo.map_or(0, |p| p.rating.min(5)) } + } + + /// What makes the drawn overlays differ (for render keys). + pub fn key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.settings.pixel_key().hash(&mut h); + self.texts.hash(&mut h); + self.rating.hash(&mut h); + h.finish() + } + + fn short(l: &Layout) -> f64 { + l.slide.width().min(l.slide.height()).max(1.0) + } + + /// The identity plate's text layout, or its image scaled to its size. + fn plate(&self, l: &Layout) -> Option { + let p = &self.settings.identity_plate; + if !p.enabled || p.opacity <= 0.0 { + return None; + } + let short = Self::short(l); + let px = (p.size * short).max(1.0); + if !p.image.trim().is_empty() { + let img = crate::export::watermark_logo(p.image.trim())?; + if img.width == 0 || img.height == 0 { + return None; + } + let h = px.round().clamp(1.0, l.slide.height().max(1.0)); + let w = (h * img.width as f64 / img.height as f64).round().clamp(1.0, l.slide.width().max(1.0)); + return Some(PlateContent::Image(img, w as usize, h as usize)); + } + let text = p.text.trim(); + crate::text::TextLayout::new(text, px as f32, false, crate::fonts::CRAFT_FONTS).map(PlateContent::Text) + } + + fn text_layout(&self, i: usize, l: &Layout) -> Option { + let t = self.settings.texts.get(i)?; + let text = self.texts.get(i)?.trim(); + if t.opacity <= 0.0 { + return None; + } + crate::text::TextLayout::new(text, (t.size * Self::short(l)).max(1.0) as f32, false, crate::fonts::CRAFT_FONTS) + } + + fn stars_size(&self, l: &Layout) -> Option<(usize, f64)> { + let r = &self.settings.rating_stars; + let n = usize::from(self.rating.min(5)); + (r.enabled && n > 0).then(|| (n, (r.size * Self::short(l)).max(2.0))) + } + + /// Where each overlay goes on the slide `l` (the ones that draw something). + pub fn boxes(&self, l: &Layout) -> Vec<(Kind, Rect)> { + let mut out = Vec::new(); + let s = &self.settings; + if let Some(c) = self.plate(l) { + let (w, h) = c.size(); + out.push((Kind::IdentityPlate, s.identity_plate.place.place(l, w, h))); + } + if let Some((n, size)) = self.stars_size(l) { + let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP; + out.push((Kind::RatingStars, s.rating_stars.place.place(l, w, size))); + } + for (i, t) in s.texts.iter().enumerate() { + if let Some(tl) = self.text_layout(i, l) { + out.push((Kind::Text(i), t.place.place(l, tl.width as f64, tl.height as f64))); + } + } + if s.show_watermark && (!s.watermark.text.trim().is_empty() || !s.watermark.image.trim().is_empty()) { + out.push((Kind::Watermark, l.visible)); + } + out + } + + /// Draw the overlays drawn behind the photo (an identity plate with "render behind image"). + pub fn draw_behind(&self, img: &mut Rgba8, l: &Layout) { + if self.settings.identity_plate.behind { + self.draw_plate(img, l); + } + } + + /// Draw the overlays over the composed slide: the watermark on the photo, then the identity + /// plate (unless it is behind), the rating stars and the text overlays. + pub fn draw_front(&self, img: &mut Rgba8, l: &Layout) { + let s = &self.settings; + if s.show_watermark { + let (x0, y0, x1, y1) = pixels(&l.visible, img.width, img.height); + if x1 > x0 && y1 > y0 { + crate::export::draw_watermark_in(img, &s.watermark, x0, y0, x1 - x0, y1 - y0); + } + } + if !s.identity_plate.behind { + self.draw_plate(img, l); + } + if let Some((n, size)) = self.stars_size(l) { + let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP; + let b = s.rating_stars.place.place(l, w, size); + for i in 0..n { + let x = b.x0 + i as f64 * size * (1.0 + STAR_GAP); + star(img, x, b.y0, size, s.rating_stars.color); + } + } + for (i, t) in s.texts.iter().enumerate() { + let Some(tl) = self.text_layout(i, l) else { continue }; + let b = t.place.place(l, tl.width as f64, tl.height as f64); + let short = Self::short(l); + if t.shadow.on && t.shadow.opacity > 0.0 { + let (d, a) = (t.shadow.offset * short, t.shadow.angle.to_radians()); + let (dx, dy) = (d * a.cos(), -d * a.sin()); + shadow(img, &tl, b.x0 + dx, b.y0 + dy, t.shadow.radius * short / 2.0, (t.shadow.opacity * t.opacity) as f32); + } + draw_text(img, &tl, b.x0, b.y0, t.color, t.opacity as f32); + } + } + + fn draw_plate(&self, img: &mut Rgba8, l: &Layout) { + let p = &self.settings.identity_plate; + let Some(c) = self.plate(l) else { return }; + let (w, h) = c.size(); + let b = p.place.place(l, w, h); + match c { + PlateContent::Text(tl) => draw_text(img, &tl, b.x0, b.y0, p.color, p.opacity as f32), + PlateContent::Image(src, w, h) => { + if let Some(scaled) = scaled_graphic(p.image.trim(), &src, w, h) { + draw_graphic(img, &scaled, b.x0.round() as i64, b.y0.round() as i64, p.opacity as f32); + } + } + } + } +} + +enum PlateContent { + Text(crate::text::TextLayout), + Image(Arc, usize, usize), +} + +impl PlateContent { + fn size(&self) -> (f64, f64) { + match self { + PlateContent::Text(t) => (t.width as f64, t.height as f64), + PlateContent::Image(_, w, h) => (*w as f64, *h as f64), + } + } +} + +/// Whole pixel bounds of `r` clipped to `w × h`. +fn pixels(r: &Rect, w: usize, h: usize) -> (usize, usize, usize, usize) { + let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 }; + let (x0, y0) = (c(r.x0, w), c(r.y0, h)); + (x0, y0, c(r.x1, w).max(x0), c(r.y1, h).max(y0)) +} + +/// Blend `c` into pixel (`x`, `y`) at `k` (0–1); outside the image does nothing. +fn blend(img: &mut Rgba8, x: i64, y: i64, k: f32, c: [u8; 3]) { + if x < 0 || y < 0 || k <= 0.0 { + return; + } + let (x, y) = (x as usize, y as usize); + if x >= img.width || y >= img.height { + return; + } + let w = img.width; + if let Some(p) = img.data.get_mut(y * w + x) { + let k = k.min(1.0); + for i in 0..3 { + p[i] = (p[i] as f32 + (c[i] as f32 - p[i] as f32) * k).round().clamp(0.0, 255.0) as u8; + } + } +} + +fn draw_text(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, c: [u8; 3], opacity: f32) { + let (w, h) = (img.width as f32, img.height as f32); + tl.draw_in(x as f32, y as f32, w, h, |gx, gy, cov| blend(img, i64::from(gx), i64::from(gy), cov * opacity, c)); +} + +/// A soft black shadow of the text at (`x`, `y`): its coverage blurred by `sigma` pixels. Only +/// the part that can reach the image is rasterised. +fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma: f64, opacity: f32) { + let pad = (sigma * 3.0).ceil().clamp(0.0, 512.0); + let (iw, ih) = (img.width as f64, img.height as f64); + let mx0 = (x - pad).floor().max(-pad); + let my0 = (y - pad).floor().max(-pad); + let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad); + let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad); + if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() { + return; + } + let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize); + let mut mask = Plane::new(w, h); + tl.draw_in((x - mx0) as f32, (y - my0) as f32, w as f32, h as f32, |gx, gy, cov| { + if gx >= 0 + && gy >= 0 + && (gx as usize) < w + && (gy as usize) < h + && let Some(m) = mask.data.get_mut(gy as usize * w + gx as usize) + { + *m = (*m + cov).min(1.0); + } + }); + let mask = if sigma > 0.3 { lightcraft_raster::blur::gaussian(&mask, sigma as f32) } else { mask }; + let (ox, oy) = (mx0 as i64, my0 as i64); + for (i, k) in mask.data.iter().enumerate() { + if *k > 1e-3 { + blend(img, ox + (i % w) as i64, oy + (i / w) as i64, k * opacity, [0, 0, 0]); + } + } +} + +/// The identity plate image scaled to its size, premultiplied: (file, its decoded image, w, h) → +/// pixels, so the slides of a slideshow don't each scale a large logo again. +type ScaledCache = std::sync::Mutex>)>>; +static SCALED: ScaledCache = std::sync::Mutex::new(None); + +/// `src` (decoded from `path`) scaled to `w × h` (premultiplied, so transparent edges stay clean). +fn scaled_graphic(path: &str, src: &Arc, w: usize, h: usize) -> Option>> { + if src.width == 0 || src.height == 0 || w == 0 || h == 0 || src.data.len() != src.width * src.height { + return None; + } + let id = Arc::as_ptr(src) as usize; + let mut cache = SCALED.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((p, i, cw, ch, img)) = cache.as_ref() + && (p.as_str(), *i, *cw, *ch) == (path, id, w, h) + { + return Some(img.clone()); + } + let pre: Image<[f32; 4]> = Image::from_fn(src.width, src.height, |x, y| { + let p = src.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = Arc::new(lightcraft_raster::resample::resize(&pre, w, h, lightcraft_raster::resample::Filter::Mitchell)); + *cache = Some((path.to_string(), id, w, h, scaled.clone())); + Some(scaled) +} + +/// The scaled graphic at (`x`, `y`), blended with its alpha × `opacity`. +fn draw_graphic(img: &mut Rgba8, scaled: &Image<[f32; 4]>, x: i64, y: i64, opacity: f32) { + for yy in 0..scaled.height { + for xx in 0..scaled.width { + let [r, g, b, a] = scaled.get(xx, yy); + let a = a.clamp(0.0, 1.0); + if a <= 1e-4 { + continue; + } + let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); + blend(img, x + xx as i64, y + yy as i64, a * opacity, c); + } + } +} + +/// The ten points of a five-pointed star in a `size` box at (`x`, `y`), point up. +fn star_points(x: f64, y: f64, size: f64) -> [(f64, f64); 10] { + let r = size / 2.0; + // the star's height is r (1 + cos 36°); centre it vertically in the box + let cy = y + size / 2.0 + r * (1.0 - (36f64).to_radians().cos()) / 2.0; + let cx = x + size / 2.0; + let inner = r * 0.382; + let mut pts = [(0.0, 0.0); 10]; + for (i, p) in pts.iter_mut().enumerate() { + let a = (90.0 + 36.0 * i as f64).to_radians(); + let rr = if i % 2 == 0 { r } else { inner }; + *p = (cx + rr * a.cos(), cy - rr * a.sin()); + } + pts +} + +/// A filled star: even-odd scanlines, four a pixel row, with exact horizontal coverage. +fn star(img: &mut Rgba8, x: f64, y: f64, size: f64, c: [u8; 3]) { + if !(x.is_finite() && y.is_finite() && size.is_finite() && size > 0.0) { + return; + } + let pts = star_points(x, y, size); + let (x0, y0) = (x.floor() as i64, y.floor() as i64); + let n = size.ceil() as usize + 2; + let mut row = vec![0f32; n]; + let mut xs: Vec = Vec::with_capacity(pts.len()); + for py in y0..y0 + n as i64 { + row.fill(0.0); + for s in 0..4 { + let sy = py as f64 + (s as f64 + 0.5) / 4.0; + xs.clear(); + for (i, &(xi, yi)) in pts.iter().enumerate() { + let Some(&(xj, yj)) = pts.get((i + pts.len() - 1) % pts.len()) else { continue }; + if (yi > sy) != (yj > sy) { + xs.push((xj - xi) * (sy - yi) / (yj - yi) + xi); + } + } + xs.sort_by(f64::total_cmp); + for &[a, b] in xs.as_chunks::<2>().0 { + let (a, b) = (a - x0 as f64, b - x0 as f64); + let first = a.floor().max(0.0) as usize; + let last = (b.ceil().max(0.0) as usize).min(n); + for (i, v) in row.iter_mut().enumerate().take(last).skip(first) { + let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)).max(0.0); + *v += cover as f32 / 4.0; + } + } + } + for (i, k) in row.iter().enumerate() { + if *k > 0.0 { + blend(img, x0 + i as i64, py, k.min(1.0), c); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout(w: usize, h: usize) -> Layout { + lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h) + } + + fn photo() -> Photo { + let mut p = crate::rename::sample_photo(); + p.rating = 3; + p.meta.caption = "Morning fog".into(); + p.meta.shutter = "1/250".into(); + p.meta.aperture = Some(8.0); + p.meta.focal_mm = Some(50.0); + p + } + + #[test] + fn tokens_expand_like_the_rename_tokens_plus_slide_ones() { + let p = photo(); + assert_eq!(expand("{caption}", &p), "Morning fog"); + assert_eq!(expand("{exposure}", &p), "1/250 s at f/8"); + assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default())); + assert_eq!(expand("{file}", &p), p.file_name); + assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work"); + assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1)); + assert_eq!(expand("{rating}", &p), "3"); + // unknown and slide-less tags show as typed, and are reported + assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {"); + assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]); + // a photo without metadata: empty values, no panic + let mut bare = p.clone(); + bare.meta = Default::default(); + assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]"); + } + + #[test] + fn nine_anchors_place_inside_the_slide_with_their_offsets() { + let l = layout(1000, 600); + let short = 600.0; + for anchor in ANCHORS { + for relative_to in [RelativeTo::Slide, RelativeTo::Photo] { + let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0); + let b = at(0.0, 0.0); + assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}"); + assert_eq!((b.width(), b.height()), (100.0, 40.0)); + let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible }; + let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); + let id = anchor_id(anchor); + let want_x = if id.ends_with("eft") { + c.x0 + 50.0 + } else if id.ends_with("ight") { + c.x1 - 50.0 + } else { + (c.x0 + c.x1) / 2.0 + }; + let want_y = if id.starts_with("top") { + c.y0 + 20.0 + } else if id.starts_with("bottom") { + c.y1 - 20.0 + } else { + (c.y0 + c.y1) / 2.0 + }; + assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}"); + // an inward offset moves it by that share of the shorter side + let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 }); + let m = at(sx, sy); + assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}"); + } + } + // pushed off the slide: kept on it + let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0); + assert_eq!((b.x1, b.y1), (1000.0, 600.0)); + } + + fn blank(w: usize, h: usize) -> Rgba8 { + Image::filled(w, h, [0, 0, 0, 255]) + } + + fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize { + let (x0, y0, x1, y1) = pixels(r, img.width, img.height); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count() + } + + #[test] + fn rating_stars_count_matches_the_rating() { + let l = layout(800, 500); + let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap(); + let mut p = photo(); + let mut widths = Vec::new(); + for rating in 0..=5u8 { + p.rating = rating; + let o = SlideOverlays::resolve(&s, Some(&p)); + let boxes = o.boxes(&l); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else { + assert_eq!(rating, 0, "no stars only for an unrated photo"); + assert_eq!(lit(&img, &l.slide, 1), 0); + continue; + }; + widths.push(b.width()); + // count the stars: separate runs of lit columns along the box's middle row + let y = ((b.y0 + b.y1) / 2.0) as usize; + let mut runs = 0; + let mut was = false; + for x in 0..800 { + let on = img.get(x, y)[1] > 128; + runs += usize::from(on && !was); + was = on; + } + assert_eq!(runs, usize::from(rating), "{rating} stars"); + } + assert!(widths.windows(2).all(|w| w[1] > w[0])); + } + + #[test] + fn text_with_a_shadow_draws_the_shadow_at_its_angle() { + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged( + &json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, + "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}), + ) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let b = o.boxes(&l)[0].1; + let mut grey = Image::filled(800, 500, [128, 128, 128, 255]); + o.draw_front(&mut grey, &l); + // straight down by 10 px: dark pixels below the text box's glyphs, none above + let dark = |r: Rect| { + let (x0, y0, x1, y1) = pixels(&r, 800, 500); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count() + }; + assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below"); + assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above"); + // without the shadow: no dark pixels at all + let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap(); + let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]); + SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l); + assert!(grey2.data.iter().all(|p| p[0] >= 128)); + } + + #[test] + fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { + let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plate.png"); + let red = Image::filled(40, 20, [255u8, 0, 0, 255]); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(&path, png).unwrap(); + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}})) + .unwrap(); + assert!(s.warnings().is_empty()); + let o = SlideOverlays::resolve(&s, None); + let b = o.boxes(&l)[0].1; + assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept"); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let p = img.get(50, 25); + assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}"); + assert_eq!(img.get(150, 25)[0], 0, "only inside its box"); + // behind the image: the photo covers it + let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); + let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); + let ob = SlideOverlays::resolve(&behind, None); + let (img, l2) = + lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { + ob.draw_behind(i, l) + }); + let _ = l2; + assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); + // a missing file: reported, nothing drawn, no panic + let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap(); + assert_eq!(gone.warnings().len(), 1); + let mut img = blank(800, 500); + SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l); + assert!(img.data.iter().all(|p| p[0] == 0)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn overlays_are_resolution_independent() { + let s = OverlaySettings::default() + .merged(&json!({ + "identityPlate": {"enabled": true, "text": "Studio", "size": 0.06}, + "ratingStars": {"enabled": true, "size": 0.04}, + "texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}], + })) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000))); + assert_eq!(a.len(), 3); + for ((ka, ra), (kb, rb)) in a.iter().zip(&b) { + assert_eq!(ka, kb); + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}"); + } + } + } + + #[test] + fn watermark_takes_the_export_insets_and_rotation() { + let s = OverlaySettings::default().merged(&json!({"watermark": {"inset": 0.1, "insetY": 0.2, "rotation": 90}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y, s.watermark.rotation), (0.1, 0.2, 90)); + let s = s.merged(&json!({"watermark": {"insetX": 0.05}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y), (0.05, 0.2)); + assert!(OverlaySettings::default().merged(&json!({"watermark": {"rotation": 45}})).is_err()); + } + + #[test] + fn hostile_settings_are_refused_or_clamped_and_never_panic() { + let d = OverlaySettings::default(); + for bad in [ + json!({"identityPlate": {"size": "big"}}), + json!({"identityPlate": {"anchor": "middle"}}), + json!({"identityPlate": 3}), + json!({"ratingStars": {"color": "#zzzzzz"}}), + json!({"texts": {"text": "a"}}), + json!({"texts": [{"relativeTo": "page"}]}), + json!({"texts": [{"sparkle": 1}]}), + json!({"texts": vec![json!({}); MAX_TEXTS + 1]}), + json!({"removeText": 0}), + json!({"removeText": -1}), + json!({"watermark": {"size": 9}}), + json!({"watermark": 5}), + json!({"showWatermark": "yes"}), + json!({"glitter": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}})) + .unwrap(); + assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX)); + assert_eq!(s.rating_stars.size, SIZE_MIN); + assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX)); + assert!(d.merged(&json!({"removeText": 0})).is_err()); + assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty()); + // round trips (command JSON and prefs.json) + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + // NaN / ∞ from a hand-edited prefs file + let mut wild = s.clone(); + wild.identity_plate.size = f64::NAN; + wild.texts[0].place.offset_y = f64::INFINITY; + wild.watermark.size = f32::NAN; + let w = wild.sanitized(); + assert_eq!( + (w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), + (IdentityPlate::default().size, 0.0, Watermark::default().size) + ); + // drawing hostile text at every size: empty, huge, CJK, RTL, control characters + let huge = "W".repeat(50_000); + for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] + { + let s = d + .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) + .unwrap(); + assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS); + let mut bare = photo(); + bare.meta = Default::default(); + for (w, h) in [(1, 1), (17, 9), (320, 200)] { + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h); + let o = SlideOverlays::resolve(&s, Some(&bare)); + let mut img = blank(l.slide.width() as usize, l.slide.height() as usize); + o.draw_behind(&mut img, &l); + o.draw_front(&mut img, &l); + for (_, b) in o.boxes(&l) { + assert!(b.x0.is_finite() && b.y1.is_finite()); + } + } + } + } + + /// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised. + #[test] + fn huge_overlays_on_a_big_slide_stay_cheap() { + let long = "Wide ".repeat(400); + let s = OverlaySettings::default() + .merged(&json!({ + "ratingStars": {"enabled": true, "size": STAR_SIZE_MAX}, + "addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}, + })) + .unwrap(); + let mut p = photo(); + p.rating = 5; + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500); + let mut img = blank(4000, 2500); + let t = std::time::Instant::now(); + SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l); + assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed()); + assert!(img.get(100, 300)[0] > 0, "the text's start shows"); + } + + /// CJK text draws with the craft-fonts faces (skips without them). + #[test] + fn cjk_text_uses_the_craft_fonts() { + if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() { + eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); + return; + } + let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap(); + let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap(); + let count = |t: &crate::text::TextLayout| { + let mut n = 0usize; + t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5)); + n + }; + assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes"); + } +} diff --git a/crates/engine/src/slide_template.rs b/crates/engine/src/slide_template.rs new file mode 100644 index 0000000000..32e26ed592 --- /dev/null +++ b/crates/engine/src/slide_template.rs @@ -0,0 +1,632 @@ +//! Slideshow templates (Lightroom Classic's Template Browser): a named snapshot of how a +//! slideshow looks and plays — the Playback settings, the slide's Layout / Options / Backdrop, +//! the Overlays and the Titles — as one JSON document with a format version. +//! +//! Built-in templates are our own designs ([`builtins`], JSON files beside this module). User +//! templates are files in the settings folder, `/slideshow-templates/.json` +//! ([`Session::slide_templates_dir`]); they are shared by every library on the machine, like +//! Lightroom's templates. Every section is read through the settings' own `merged()` (so a +//! hand-edited or hostile file can never set a value the panels couldn't), files are capped in +//! size and number, a file that can't be used is reported and skipped, and file names are made +//! from the template's name so they can never leave the templates folder. +//! +//! Applying a template replaces the four settings in one step and saves them with the library. +//! Library preferences have no undo, so the settings it replaced are kept for the session as +//! "Previous settings" ([`PREVIOUS`]): applying that puts them back (and applying it again goes +//! forward). "Prepare previews" is a machine preference, not part of a look: templates neither +//! save nor change it. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Map, Value, json}; + +use crate::Session; +use crate::slide::SlideSettings; +use crate::slide_overlay::OverlaySettings; +use crate::slide_title::TitleSettings; +use crate::slideshow::PlaybackSettings; + +/// The `format` of a template document. +pub const FORMAT: &str = "lightcraft.slideshowTemplate"; +/// The newest document version this build reads (and the one it writes). +pub const VERSION: u64 = 1; +/// Largest template file read (bytes). +pub const MAX_FILE: u64 = 256 * 1024; +/// Most user templates listed (the rest of a folder that holds more are reported, not read). +pub const MAX_TEMPLATES: usize = 1000; +/// Directory entries looked at before giving up on a huge folder. +const MAX_SCAN: usize = 20_000; +/// Longest template name (characters). +pub const NAME_MAX: usize = 80; +/// Longest description (characters). +pub const DESCRIPTION_MAX: usize = 500; +/// Longest file stem made from a name (characters). +const STEM_MAX: usize = 64; +/// The folder under the settings folder user templates live in. +pub const DIR_NAME: &str = "slideshow-templates"; +/// Id prefix of built-in templates. +pub const BUILTIN: &str = "builtin:"; +/// Id prefix of user templates (followed by the file stem). +pub const USER: &str = "user:"; +/// Id of the settings before the last template was applied. +pub const PREVIOUS: &str = "previous"; + +/// Everything a template sets. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SlideLook { + pub playback: PlaybackSettings, + pub layout: SlideSettings, + pub overlays: OverlaySettings, + pub titles: TitleSettings, +} + +impl SlideLook { + /// The library's current slideshow settings. + pub fn of(s: &Session) -> SlideLook { + SlideLook { + playback: s.slideshow.clone(), + layout: s.slide_layout.clone(), + overlays: s.slide_overlays.clone(), + titles: s.slide_titles.clone(), + } + } + + /// The four sections as JSON (playback without `preparePreviews`, which isn't part of a look). + pub fn sections(&self) -> Map { + let mut playback = self.playback.to_json(); + if let Some(o) = playback.as_object_mut() { + o.remove("preparePreviews"); + } + let mut m = Map::new(); + m.insert("playback".into(), playback); + m.insert("layout".into(), self.layout.to_json()); + m.insert("overlays".into(), self.overlays.to_json()); + m.insert("titles".into(), self.titles.to_json()); + m + } + + /// Read the sections of a template document: each one merged onto the defaults by the + /// settings' own validation (a missing section is the defaults; a wrong type, an unknown key + /// or a non-finite number is an error). + fn from_sections(doc: &Map) -> Result { + fn section(doc: &Map, key: &str, merge: impl Fn(&Value) -> Result, default: T) -> Result { + match doc.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v @ Value::Object(_)) => merge(v).map_err(|e| format!("{key}: {e}")), + Some(v) => Err(format!("`{key}` must be an object, not {v}")), + } + } + let mut playback = section(doc, "playback", |v| PlaybackSettings::default().merged(v), PlaybackSettings::default())?; + playback.prepare_previews = PlaybackSettings::default().prepare_previews; + Ok(SlideLook { + playback, + layout: section(doc, "layout", |v| SlideSettings::default().merged(v), SlideSettings::default())?, + overlays: section(doc, "overlays", |v| OverlaySettings::default().merged(v), OverlaySettings::default())?, + titles: section(doc, "titles", |v| TitleSettings::default().merged(v), TitleSettings::default())?, + }) + } + + /// What doesn't work with this look in `s`'s library: a background photo that isn't in it, + /// an identity plate or watermark image file that can't be read, unknown tokens, a title + /// screen with nothing to show. The look still applies; these draw nothing. + pub fn warnings(&self, s: &Session) -> Vec { + let mut out = Vec::new(); + if let Some(id) = self.layout.background_image + && s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none() + { + out.push(format!("the background image (photo {id}) isn't in this library; the backdrop colour shows instead")); + } + out.extend(self.overlays.warnings()); + let w = &self.overlays.watermark; + if self.overlays.show_watermark && !w.image.trim().is_empty() && crate::export::watermark_logo(w.image.trim()).is_none() { + out.push(format!("watermark image {} can't be read; nothing is drawn", w.image.trim())); + } + for t in self.titles.warnings(&self.overlays) { + if !out.contains(&t) { + out.push(t); + } + } + out + } +} + +/// A template: built in or a user's file. +#[derive(Clone, Debug, PartialEq)] +pub struct Template { + /// `builtin:` or `user:`. + pub id: String, + pub name: String, + pub description: String, + pub builtin: bool, + /// The file a user template is read from. + pub path: Option, + pub look: SlideLook, +} + +impl Template { + /// The template as a document (what a user template's file holds, and what export writes). + pub fn to_doc(&self) -> Value { + doc(&self.name, &self.description, &self.look) + } + + /// The template for listings (no settings). + pub fn info(&self) -> Value { + let mut v = json!({ + "id": self.id, + "name": self.name, + "builtin": self.builtin, + "group": if self.builtin { "builtin" } else { "user" }, + }); + if !self.description.is_empty() { + v["description"] = json!(self.description); + } + if let Some(p) = &self.path { + v["path"] = json!(p.to_string_lossy()); + } + v + } +} + +/// A template document for `look`. +pub fn doc(name: &str, description: &str, look: &SlideLook) -> Value { + let mut m = Map::new(); + m.insert("format".into(), json!(FORMAT)); + m.insert("version".into(), json!(VERSION)); + m.insert("name".into(), json!(name)); + if !description.is_empty() { + m.insert("description".into(), json!(description)); + } + m.extend(look.sections()); + Value::Object(m) +} + +/// A template document read and checked: its name (`None` when it has none), description and look. +#[derive(Clone, Debug, PartialEq)] +pub struct Parsed { + pub name: Option, + pub description: String, + pub look: SlideLook, +} + +/// Read a template document. Refused: more than [`MAX_FILE`] bytes, not JSON, another format, a +/// version from a newer LightCraft, a bad name, unknown keys and any setting its section's +/// validation refuses. +pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() as u64 > MAX_FILE { + return Err(format!("{} bytes is too large for a slideshow template (at most {} KB)", bytes.len(), MAX_FILE / 1024)); + } + let text = std::str::from_utf8(bytes).map_err(|_| "not a slideshow template (not UTF-8 text)".to_string())?; + let text = text.trim_start_matches('\u{feff}'); + let v: Value = serde_json::from_str(text).map_err(|e| format!("not a slideshow template: {e}"))?; + let Value::Object(o) = v else { return Err("not a slideshow template (not a JSON object)".into()) }; + match o.get("format").and_then(Value::as_str) { + Some(FORMAT) => {} + Some(other) => return Err(format!("not a slideshow template (format `{other}`, expected `{FORMAT}`)")), + None => return Err(format!("not a slideshow template (no `format`: \"{FORMAT}\")")), + } + match o.get("version") { + Some(v) => match v.as_u64() { + Some(n) if n > VERSION => { + return Err(format!( + "made by a newer LightCraft (template version {n}; this one reads version {VERSION}): update LightCraft to use it" + )); + } + Some(n) if n >= 1 => {} + _ => return Err(format!("`version` must be a whole number from 1, not {v}")), + }, + None => return Err("no `version`".into()), + } + for k in o.keys() { + if !matches!(k.as_str(), "format" | "version" | "name" | "description" | "playback" | "layout" | "overlays" | "titles") { + return Err(format!("unknown key `{k}` (format, version, name, description, playback, layout, overlays, titles)")); + } + } + let name = match o.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if n.trim().is_empty() => None, + Some(Value::String(n)) => Some(valid_name(n)?), + Some(v) => return Err(format!("`name` must be text, not {v}")), + }; + let description = match o.get("description") { + None | Some(Value::Null) => String::new(), + Some(Value::String(d)) => clean_text(d, DESCRIPTION_MAX), + Some(v) => return Err(format!("`description` must be text, not {v}")), + }; + Ok(Parsed { name, description, look: SlideLook::from_sections(&o)? }) +} + +/// `s` without control characters, trimmed, at most `max` characters. +fn clean_text(s: &str, max: usize) -> String { + s.chars().filter(|c| !c.is_control()).take(max).collect::().trim().to_string() +} + +/// A template name as stored: trimmed, 1–[`NAME_MAX`] characters, no control characters. +pub fn valid_name(name: &str) -> Result { + let n = name.trim(); + if n.is_empty() { + return Err("a template needs a name".into()); + } + if n.chars().any(char::is_control) { + return Err("a template name can't contain control characters (line breaks, tabs…)".into()); + } + if n.chars().count() > NAME_MAX { + return Err(format!("a template name has at most {NAME_MAX} characters")); + } + Ok(n.to_string()) +} + +/// The file stem a template called `name` is saved as: letters, digits, spaces, `-`, `_`, `(` +/// and `)` kept, anything else (path separators, dots, `:`, `*`…) `_`; trimmed; at most 64 +/// characters; Windows' reserved device names get a `_`. `None` when nothing usable is left. +pub fn file_stem(name: &str) -> Option { + let s: String = + name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '(' | ')') { c } else { '_' }).take(STEM_MAX).collect(); + let s = s.trim().to_string(); + if s.is_empty() || s.chars().all(|c| c == '_') { + return None; + } + let upper = s.to_ascii_uppercase(); + let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ((upper.starts_with("COM") || upper.starts_with("LPT")) && upper.len() == 4 && upper.ends_with(|c: char| c.is_ascii_digit())); + Some(if reserved { format!("{s}_") } else { s }) +} + +/// The stem of a user template id, when it is one this module could have made (so it can't +/// name a file outside the templates folder). +fn user_stem(id: &str) -> Option<&str> { + let stem = id.strip_prefix(USER)?; + (file_stem(stem).as_deref() == Some(stem)).then_some(stem) +} + +/// The built-in templates: our own designs (no Lightroom template, name or design copied). +const BUILTINS: &[(&str, &str)] = &[ + ("plain-black", include_str!("slideshow_templates/plain-black.json")), + ("gallery-wall", include_str!("slideshow_templates/gallery-wall.json")), + ("edge-to-edge", include_str!("slideshow_templates/edge-to-edge.json")), + ("cinema-wide", include_str!("slideshow_templates/cinema-wide.json")), + ("shooting-data", include_str!("slideshow_templates/shooting-data.json")), +]; + +/// The built-in templates (one that doesn't parse — a bug the tests catch — is left out). +pub fn builtins() -> Vec